feat(admin): Settings 分區即時儲存(backport A v3.0.2)

7 個設定分區各自加「儲存此區塊」按鈕,經 wp_ajax_wpdo_save_settings_section
以 AJAX 存檔,不再需要整頁 reload。

- 新增 ajax_save_settings_section() + 7 個 private save_section_*()
- 新增 admin/assets/wpdo-settings.js(106 行)與 4 條 CSS 規則
- render_settings() 的 7 個 h3 各包上 .wpdo-settings-section[data-section]
- 全頁送出按鈕改標「儲存全部設定」,原本的 POST handler 保持不變(漸進增強)

hp-transient / wc-term-count 兩個分區的 toggle 仍由核心 admin 呈現(實作在
AddOn),只寫 wp_options,故不需 class_exists 守衛。
This commit is contained in:
2026-07-31 10:01:20 +08:00
parent fa356a63b1
commit 2203bc471c
3 changed files with 319 additions and 1 deletions
+28
View File
@@ -513,3 +513,31 @@
animation: none;
}
}
/* ── Settings per-section instant save (v3.0.2 backport) ────────────── */
.wpdo-settings-section {
background: var(--color-surface-card, #fbf7f0);
border: 1px solid var(--color-border-soft, #e5dccd);
border-radius: var(--radius-md, 12px);
padding: var(--space-4, 16px) var(--space-6, 24px);
margin-bottom: var(--space-5, 20px);
}
.wpdo-section-save-row {
display: flex;
align-items: center;
gap: var(--space-3, 12px);
padding-top: var(--space-3, 12px);
}
.wpdo-section-save-status {
font-size: 0.9rem;
color: var(--color-success, #5a8c5a);
opacity: 0;
transition: opacity var(--duration-normal, 200ms) var(--ease-default, ease);
}
.wpdo-section-save-status.visible {
opacity: 1;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* WP Data Optimizer — Settings per-section instant save.
*
* Each .wpdo-settings-section card has a "儲存此區塊" button that posts only
* that section's fields via AJAX, updating options without a full page reload.
*
* @since 3.0.2
*/
/* global wpdoSettings */
( function () {
'use strict';
if ( typeof wpdoSettings === 'undefined' ) {
return;
}
const { ajaxUrl, nonce, i18n } = wpdoSettings;
/**
* Collect form fields within a section into a FormData object.
* Unchecked checkboxes are intentionally omitted (server does isset() ? '1' : '0').
*
* @param {HTMLElement} section
* @returns {FormData}
*/
function collectFields( section ) {
const fd = new FormData();
section.querySelectorAll( 'input, select, textarea' ).forEach( ( el ) => {
if ( ! el.name ) {
return;
}
if ( el.type === 'checkbox' ) {
if ( el.checked ) {
fd.append( el.name, el.value );
}
} else if ( el.type === 'radio' ) {
if ( el.checked ) {
fd.append( el.name, el.value );
}
} else {
fd.append( el.name, el.value );
}
} );
return fd;
}
/**
* Show a transient status message, then fade it out after 2.5s.
*
* @param {HTMLElement} statusEl
* @param {string} message
* @param {boolean} isError
*/
function showStatus( statusEl, message, isError ) {
statusEl.textContent = message;
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer );
statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' );
}, 2500 );
}
document.addEventListener( 'click', function ( e ) {
const btn = e.target.closest( '.wpdo-section-save' );
if ( ! btn ) {
return;
}
const section = btn.closest( '.wpdo-settings-section' );
const statusEl = btn.nextElementSibling;
const sectionKey = section ? section.dataset.section : '';
if ( ! sectionKey ) {
return;
}
const originalLabel = btn.textContent;
btn.disabled = true;
btn.textContent = i18n.saving;
const fd = collectFields( section );
fd.append( 'action', 'wpdo_save_settings_section' );
fd.append( '_ajax_nonce', nonce );
fd.append( 'section', sectionKey );
fetch( ajaxUrl, {
method : 'POST',
credentials : 'same-origin',
body : fd,
} )
.then( ( r ) => r.json() )
.then( ( data ) => {
if ( data.success ) {
showStatus( statusEl, i18n.saved, false );
} else {
showStatus( statusEl, i18n.error, true );
}
} )
.catch( () => {
showStatus( statusEl, i18n.error, true );
} )
.finally( () => {
btn.disabled = false;
btn.textContent = originalLabel;
} );
} );
} )();
+185 -1
View File
@@ -44,6 +44,7 @@ class TMDO_Admin {
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'network_admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'wp_ajax_wpdo_admin_action', array( __CLASS__, 'handle_ajax' ) );
add_action( 'wp_ajax_wpdo_save_settings_section', array( __CLASS__, 'ajax_save_settings_section' ) );
add_action( 'admin_notices', array( __CLASS__, 'maybe_nginx_backup_notice' ) );
}
@@ -300,6 +301,28 @@ class TMDO_Admin {
),
)
);
// Settings per-section instant save.
wp_enqueue_script(
'wpdo-settings',
TMDO_URL . 'admin/assets/wpdo-settings.js',
array(),
TMDO_VERSION,
true
);
wp_localize_script(
'wpdo-settings',
'wpdoSettings',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'wpdo_save_settings' ),
'i18n' => array(
'saving' => __( '儲存中…', '2meet-data-optimizer' ),
'saved' => __( '✓ 已儲存', '2meet-data-optimizer' ),
'error' => __( '儲存失敗,請重試。', '2meet-data-optimizer' ),
),
)
);
}
/**
@@ -3211,6 +3234,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
<?php wp_nonce_field( 'wpdo_save_settings' ); ?>
<input type="hidden" name="wpdo_save_settings" value="1">
<div class="wpdo-settings-section" data-section="notifications">
<h3><?php esc_html_e( '📧 Email 警報', '2meet-data-optimizer' ); ?></h3>
<p class="description"><?php esc_html_e( '預設關閉。啟用後,每日健康檢查 cron 發現 critical 警告時會發信。同一個警告在 throttle 視窗內不重發。', '2meet-data-optimizer' ); ?></p>
<table class="form-table" role="presentation">
@@ -3311,6 +3335,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="entity-bridge">
<h3 style="margin-top: 2em;"><?php esc_html_e( '⚡ Entity Bridgev2.5.4 / post v2.11.0', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( 'Hook Bus 攔截 WordPress 原生 metadata filter,將 user / post / term / comment meta 路由至扁平化資料表。', '2meet-data-optimizer' ); ?><br>
@@ -3387,6 +3417,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
OR option_name LIKE '\\_transient\\_timeout\\_wpdo\\_hp\\_pm\\_%'"
);
?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="hp-transient">
<h3 style="margin-top: 2em;"><?php esc_html_e( '🌿 HivePress Transient Filterv2.11.5', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( 'HivePress 內部用 update_post_meta($post_id, \'_transient_<name>\', $value) 把 TTL cache 寫進 wp_postmeta(每個 hp_listing publish 觸發 8-16 個 transient row)。本 filter 在 metadata 層攔截並重新路由到 wp_optionsnative transient API),HivePress 完全無感,wp_postmeta 保持乾淨。Filter 是 metadata 層運作,不依賴 mode promote。', '2meet-data-optimizer' ); ?>
@@ -3477,6 +3513,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
OR meta_key IN ('_hp_price','_hp_status','_hp_featured','_hp_verified','_hp_view_count','_thumbnail_id','_edit_lock','_edit_last')"
);
?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="garbage-filter">
<h3 style="margin-top: 2em;"><?php esc_html_e( '🗑 Term + Comment Garbage Filterv2.12.1', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( '攔截 wp_termmeta / wp_commentmeta 已知垃圾 keys 的寫入並 silent drop_wxr_import_* WP 匯入殘留、_2meet_demo_* demo 標記、commentmeta 中 8 個誤寫的 post-domain orphan keys)。Phase 0 cleanup CLI 清歷史,本 filter 防再次累積。Read 路徑不攔截(向後相容)。', '2meet-data-optimizer' ); ?>
@@ -3557,6 +3599,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
WHERE option_name LIKE '\\_transient\\_wpdo\\_wc\\_termcount\\_%'"
);
?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="wc-term-count">
<h3 style="margin-top: 2em;"><?php esc_html_e( '🛒 WooCommerce Term Count Filterv2.12.3', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( '攔截 WooCommerce 寫入 wp_termmeta 的 product_count_<taxonomy> cache rows,重新路由到 wp_optionsnative transient 結構)。WC 自身 cache 失效邏輯不變(每次新增/刪除 product 時會重算寫入),僅儲存位置改變。Read 路徑亦會優先從 wp_options 讀回,cache miss 才 fall-through 至 wp_termmeta(向後相容)。', '2meet-data-optimizer' ); ?>
@@ -3619,6 +3667,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
? TMDO_Term_Comment_Misc_Bucket::count_rows( 'comment' )
: 0;
?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="misc-bucket">
<h3 style="margin-top: 2em;"><?php esc_html_e( '📦 Term + Comment Misc Bucketv2.12.4', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( 'Catch-all flat 表,捕捉所有未被前面 filter 處理的 term/comment meta keyspriority 99,整條 filter chain 的最後一棒)。讓 wp_termmeta / wp_commentmeta 完全可避開(v3.0.0 DROP 前置條件)。寫入 wp_wpdo_term_misc / wp_wpdo_comment_misc 兩張 K/V 表,PRIMARY KEY (entity_id, meta_key)。讀取 cache miss 時 fall-through 至 wp_*meta 維持向後相容。', '2meet-data-optimizer' ); ?>
@@ -3665,6 +3719,12 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
</tbody>
</table>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<div class="wpdo-settings-section" data-section="automator">
<h3 style="margin-top: 2em;"><?php esc_html_e( '🤖 FSM Automatorv2.5.0 M13', '2meet-data-optimizer' ); ?></h3>
<p class="description"><?php esc_html_e( '預設關閉。啟用後,每日 04:30 UTC 跑一次,自動執行 FSM Advisor 標記為 PROMOTE 的 module 推進。Destructive 轉態(verify→cutover、cutover→cleanup、cleanup→complete)即使 enabled 也永不自動。', '2meet-data-optimizer' ); ?></p>
<table class="form-table" role="presentation">
@@ -3703,10 +3763,134 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
</tbody>
</table>
<?php submit_button( __( '儲存設定', '2meet-data-optimizer' ) ); ?>
<div class="wpdo-section-save-row">
<button type="button" class="button wpdo-section-save"><?php esc_html_e( '儲存此區塊', '2meet-data-optimizer' ); ?></button>
<span class="wpdo-section-save-status" aria-live="polite"></span>
</div>
</div><!-- /.wpdo-settings-section -->
<?php submit_button( __( '儲存全部設定', '2meet-data-optimizer' ) ); ?>
</form>
<?php
}
// ── Per-section save helpers ──────────────────────────────────────────
// These private helpers are always invoked from ajax_save_settings_section()
// which calls check_ajax_referer() first. PHPCS cannot trace the call chain.
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by ajax_save_settings_section() caller
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle -- internal helpers
private static function save_section_notifications(): void {
update_option( 'wpdo_email_alerts_enabled', isset( $_POST['wpdo_email_alerts_enabled'] ) ? '1' : '0', false );
$recipient = isset( $_POST['wpdo_alert_email'] ) ? sanitize_email( wp_unslash( (string) $_POST['wpdo_alert_email'] ) ) : '';
update_option( 'wpdo_alert_email', $recipient, false );
$throttle = isset( $_POST['wpdo_alert_throttle_hours'] ) ? max( 1, min( 168, (int) $_POST['wpdo_alert_throttle_hours'] ) ) : 24;
update_option( 'wpdo_alert_throttle_hours', $throttle, false );
foreach ( array( 'slack', 'discord', 'telegram' ) as $ch ) {
update_option( "wpdo_{$ch}_enabled", isset( $_POST[ "wpdo_{$ch}_enabled" ] ) ? '1' : '0', false );
$thr = isset( $_POST[ "wpdo_{$ch}_throttle_hours" ] ) ? max( 1, min( 168, (int) $_POST[ "wpdo_{$ch}_throttle_hours" ] ) ) : 24;
update_option( "wpdo_{$ch}_throttle_hours", $thr, false );
$sev = isset( $_POST[ "wpdo_{$ch}_severity" ] ) ? sanitize_key( wp_unslash( (string) $_POST[ "wpdo_{$ch}_severity" ] ) ) : 'critical_only';
if ( ! in_array( $sev, array( 'critical_only', 'critical_and_recommended' ), true ) ) {
$sev = 'critical_only';
}
update_option( "wpdo_{$ch}_severity", $sev, false );
}
foreach ( array( 'wpdo_slack_webhook', 'wpdo_discord_webhook' ) as $webhook_key ) {
if ( ! isset( $_POST[ $webhook_key ] ) ) {
continue;
}
$webhook = esc_url_raw( wp_unslash( (string) $_POST[ $webhook_key ] ) );
if ( '' === $webhook ) {
continue;
}
if ( class_exists( 'TMDO_Crypto' ) ) {
TMDO_Crypto::set_option( $webhook_key, $webhook );
} else {
update_option( $webhook_key, $webhook, false );
}
}
if ( isset( $_POST['wpdo_telegram_bot_token'] ) ) {
$token = sanitize_text_field( wp_unslash( (string) $_POST['wpdo_telegram_bot_token'] ) );
if ( '' !== $token ) {
if ( class_exists( 'TMDO_Crypto' ) ) {
TMDO_Crypto::set_option( 'wpdo_telegram_bot_token', $token );
} else {
update_option( 'wpdo_telegram_bot_token', $token, false );
}
}
}
if ( isset( $_POST['wpdo_telegram_chat_id'] ) ) {
update_option( 'wpdo_telegram_chat_id', sanitize_text_field( wp_unslash( (string) $_POST['wpdo_telegram_chat_id'] ) ), false );
}
}
private static function save_section_entity_bridge(): void {
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
}
private static function save_section_hp_transient(): void {
update_option( 'wpdo_hp_transient_filter_enabled', isset( $_POST['wpdo_hp_transient_filter_enabled'] ) ? '1' : '0', false );
}
private static function save_section_garbage_filter(): void {
update_option( 'wpdo_term_comment_garbage_filter_enabled', isset( $_POST['wpdo_term_comment_garbage_filter_enabled'] ) ? '1' : '0', false );
}
private static function save_section_wc_term_count(): void {
update_option( 'wpdo_wc_term_count_filter_enabled', isset( $_POST['wpdo_wc_term_count_filter_enabled'] ) ? '1' : '0', false );
}
private static function save_section_misc_bucket(): void {
update_option( 'wpdo_term_comment_misc_bucket_enabled', isset( $_POST['wpdo_term_comment_misc_bucket_enabled'] ) ? '1' : '0', false );
}
private static function save_section_automator(): void {
update_option( 'wpdo_automator_enabled', isset( $_POST['wpdo_automator_enabled'] ) ? '1' : '0', false );
$blacklist = isset( $_POST['wpdo_automator_blacklist'] ) && is_array( $_POST['wpdo_automator_blacklist'] )
? array_values( array_filter( array_map( 'sanitize_key', wp_unslash( $_POST['wpdo_automator_blacklist'] ) ) ) )
: array();
update_option( 'wpdo_automator_blacklist', $blacklist, false );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
/**
* AJAX: save a single settings section without a full page reload.
*/
public static function ajax_save_settings_section(): void {
check_ajax_referer( 'wpdo_save_settings' );
if ( ! TMDO_Capability::current_user_can_admin() ) {
wp_send_json_error( 'unauthorized', 403 );
}
$section = isset( $_POST['section'] ) ? sanitize_key( wp_unslash( (string) $_POST['section'] ) ) : '';
$handlers = array(
'notifications' => 'save_section_notifications',
'entity-bridge' => 'save_section_entity_bridge',
'hp-transient' => 'save_section_hp_transient',
'garbage-filter' => 'save_section_garbage_filter',
'wc-term-count' => 'save_section_wc_term_count',
'misc-bucket' => 'save_section_misc_bucket',
'automator' => 'save_section_automator',
);
if ( ! isset( $handlers[ $section ] ) ) {
wp_send_json_error( 'invalid_section', 400 );
}
self::{$handlers[ $section ]}();
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
}
}
// Boot admin hooks.