Files
2meet-data-optimizer/admin/class-tmdo-dashboard-widget.php
wpdev d52d604d7a
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 8s
Tests / Unit Tests (push) Successful in 10s
Tests / Integration Tests (push) Successful in 35s
Tests / PHP Lint (push) Successful in 7s
Tests / PHPCS (push) Successful in 19s
Tests / PHPStan (push) Successful in 24s
style(admin): inline style 全面改用 CSS class(PR-H)
admin/ 的 inline style= 由 348 處降到 9 處,剩下的 9 處全是 CSS custom
property 載體(--wpdo-bar-width / --wpdo-cov-pct 等動態數值),與來源外掛的
設計一致。

- wpdo-admin.css 543 → 911 行:補上 148 個 class(來源檔在 selector 與
  rule-block 層級都是既有內容的超集,逐條核對過),另加 .wpdo-form-inline
- 5 個變數改為 class 版本:$mode_cls / $badge_mode_cls / $diffs_cls /
  $ap_cls / $e_mode_cls;3 個 stress-test template 補 $mode_text_cls /
  $mode_card_cls
- 刪掉因此變成孤兒的 $mode_badge / $style / $mode_style / $badge_style /
  $e_mode_bg / $badge_bg
- entity card 改 .wpdo-eb-card、pipeline dot 改 .wpdo-stage-pill、demote 按鈕
  改 .wpdo-eb-btn-dim;.wpdo-native-counts / .wpdo-recommendation /
  .wpdo-setup-banner-btn 的重複 inline style 移除
- dashboard 的 top-views 查詢一併改用上一個 commit 的 TMDO_Zone_Warm::VIEW_KEY

PHPStan 抓出自動轉換造成的 11 個未定義變數,已全數補回定義並複驗。
2026-07-31 10:34:57 +08:00

449 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
*
* Adds a "WP Data Optimizer 健康狀態" widget on the WP admin dashboard for
* users with `manage_options`. Surfaces:
* - Today's health check status (green / yellow / red traffic light)
* - Consecutive green days streak
* - 3 quick statslast snapshot age, largest zone table, oldest open conflict
* - Quick links to Run Health Check, Entity Bridge, Create Snapshot
*
* Uses transient cache (5 minutes) to keep the dashboard responsive.
*
* @package WP_Data_Optimizer
*/
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Dashboard widget — stateless static API.
*/
class TMDO_Dashboard_Widget {
public const WIDGET_ID = 'wpdo_health_widget';
public const CACHE_TTL = 300;
/**
* Register the dashboard widget hook.
*
* @return void
*/
public static function register(): void {
add_action( 'wp_dashboard_setup', array( __CLASS__, 'add_widget' ) );
}
/**
* Add the widget if user has manage_options.
*
* @return void
*/
public static function add_widget(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
wp_add_dashboard_widget(
self::WIDGET_ID,
__( 'WP Data Optimizer 健康狀態', '2meet-data-optimizer' ),
array( __CLASS__, 'render' )
);
}
/**
* Render the widget HTML.
*
* @return void
*/
public static function render(): void {
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
$bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url );
$status = self::compute_status();
$light = self::traffic_light( $status['level'] );
?>
<style>
.wpdo-widget-light { display: inline-block; width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; margin-right: 6px; }
.wpdo-widget-light.good { background: #46b450; box-shadow: 0 0 8px rgba(70,180,80,0.5); }
.wpdo-widget-light.warn { background: #dba617; box-shadow: 0 0 8px rgba(219,166,23,0.5); }
.wpdo-widget-light.crit { background: #dc3232; box-shadow: 0 0 8px rgba(220,50,50,0.5); }
.wpdo-widget-light.gray { background: #c3c4c7; }
.wpdo-widget-stat { display: flex; justify-content: space-between; padding: 0.4em 0; border-bottom: 1px solid #f0f0f1; }
.wpdo-widget-stat:last-child { border-bottom: none; }
.wpdo-widget-actions { margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #c3c4c7; }
.wpdo-widget-actions a { margin-right: 0.5em; }
</style>
<p class="wpdo-widget-intro">
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
<strong><?php echo esc_html( $light['label'] ); ?></strong>
<?php if ( $status['streak'] > 0 ) : ?>
·
<?php
printf(
/* translators: %d: days */
esc_html__( '連續綠 %d 天', '2meet-data-optimizer' ),
(int) $status['streak']
);
?>
<?php endif; ?>
</p>
<?php if ( '' !== $status['summary_msg'] ) : ?>
<p class="description wpdo-mb-2"><?php echo esc_html( $status['summary_msg'] ); ?></p>
<?php endif; ?>
<div class="wpdo-widget-stats">
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最近快照', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['last_snapshot'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最大 zone 表', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['largest_zone'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '未處理衝突', '2meet-data-optimizer' ); ?></span>
<span>
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
class="wpdo-mt-1"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['postmeta_human'] ); ?></strong></span>
</div>
</div>
<?php
// v2.5.0 M16: actionable module suggestions count.
$actionable_count = 0;
if ( class_exists( 'TMDO_Module_Detector' ) ) {
$cached = TMDO_Module_Detector::get_cached();
if ( is_array( $cached ) && isset( $cached['results'] ) ) {
foreach ( (array) $cached['results'] as $r ) {
// v2.5.0 polish: align threshold with admin tab's get_actionable() default (0.5).
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) && (float) ( $r['confidence'] ?? 0 ) >= 0.5 ) {
++$actionable_count;
}
}
}
}
if ( $actionable_count > 0 ) :
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
?>
<p class="wpdo-widget-notice wpdo-widget-notice-warn">
🤖
<?php
printf(
/* translators: %d: count */
esc_html__( '偵測到 %d 個建議啟用的 module —', '2meet-data-optimizer' ),
(int) $actionable_count
);
?>
<a href="<?php echo esc_url( $ms_url ); ?>"><?php esc_html_e( '看建議', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.8.1: surface user-meta migration residue → CTA to one-click wizard.
$attn = class_exists( 'TMDO_Migration_Orchestrator' )
? TMDO_Migration_Orchestrator::needs_attention()
: array( 'needs' => false );
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
?>
<p class="wpdo-widget-notice wpdo-widget-notice-error">
🔴
<?php
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
$msg = __( '偵測到 <strong>%1$s</strong> 行 wp_usermeta EAV 殘留橫跨 <strong>%2$s</strong> 個 entity group(當前 ratio 1:<strong>%3$s</strong>)—', '2meet-data-optimizer' );
printf(
wp_kses( $msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $attn['eav_rows'] ) ),
esc_html( (string) (int) $attn['groups_with_residue'] ),
esc_html( (string) $attn['ratio'] )
);
?>
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
</p>
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
<p class="wpdo-widget-notice wpdo-widget-notice-info">
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.9.0 Phase 0: wp_postmeta garbage CTA (transients/_wp_old_date/stale _edit_lock).
$gc = class_exists( 'TMDO_Postmeta_Cleaner' )
? TMDO_Postmeta_Cleaner::count_garbage( TMDO_Postmeta_Cleaner::TARGET_ALL )
: array( 'total' => 0 );
if ( ! empty( $gc['total'] ) && (int) $gc['total'] > 0 ) :
$confirm_msg = sprintf(
/* translators: %s: total garbage row count */
esc_html__( '即將從 wp_postmeta 刪除 %s 行垃圾資料(transients + _wp_old_date + 過期 _edit_lock)。確認執行?', '2meet-data-optimizer' ),
number_format_i18n( (int) $gc['total'] )
);
?>
<p class="wpdo-widget-notice wpdo-widget-notice-neutral">
🧹
<?php
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
$gc_msg = __( 'wp_postmeta 偵測到 <strong>%1$s</strong> 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)—', '2meet-data-optimizer' );
printf(
wp_kses( $gc_msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $gc['total'] ) ),
esc_html( number_format_i18n( (int) $gc['transients'] ) ),
esc_html( number_format_i18n( (int) $gc['wp_old_date'] ) ),
esc_html( number_format_i18n( (int) $gc['edit_locks'] ) )
);
?>
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
<button type="submit" class="button-link"
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
</button>
</form>
</p>
<?php endif; ?>
<?php
// v2.9.4: Post Entity diagnostics (independent of user-side needs_attention()).
// Shows wp_posts:wp_postmeta ratio, post mode, and total EAV residue
// across all 7 entity groups. Read-only summary; full wizard ships v2.9.5.
$post_diag = class_exists( 'TMDO_Post_Migration' ) ? TMDO_Post_Migration::diagnose() : null;
if ( null !== $post_diag && $post_diag['posts'] > 0 ) :
$total_eav = 0;
foreach ( $post_diag['groups'] as $g ) {
$total_eav += (int) $g['eav_rows'];
}
$post_color = $total_eav > 0 ? '#dba617' : '#46b450';
$post_bg = $total_eav > 0 ? '#fffbe5' : '#ecf7ed';
$post_label = $total_eav > 0
? sprintf(
/* translators: 1: total EAV rows, 2: ratio */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>mode=<strong>%3$s</strong>)—', '2meet-data-optimizer' ),
'%1$s',
'%2$s',
'%3$s'
)
: sprintf(
/* translators: 1: ratio */
__( 'Post Entity:無 EAV 殘留(ratio 1:<strong>%1$s</strong>mode=<strong>%2$s</strong>', '2meet-data-optimizer' ),
'%1$s',
'%2$s'
);
?>
<p class="wpdo-widget-notice <?php echo $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok'; ?>">
📦
<?php
if ( $total_eav > 0 ) {
printf(
wp_kses(
/* translators: 1: total EAV rows, 2: ratio, 3: mode */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>mode=<strong>%3$s</strong>)— v2.9.5 將提供一鍵遷移 UI。', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( number_format_i18n( $total_eav ) ),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
} else {
printf(
wp_kses(
/* translators: 1: ratio, 2: mode */
__( 'Post Entity:✓ 無 EAV 殘留(ratio 1:<strong>%1$s</strong>mode=<strong>%2$s</strong>', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
}
?>
</p>
<?php endif; ?>
<div class="wpdo-widget-actions">
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_run_health" value="1">
<?php wp_nonce_field( 'wpdo_run_health' ); ?>
<button type="submit" class="button button-small button-primary"><?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?></button>
</form>
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
</a>
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_create_snapshot" value="1">
<?php wp_nonce_field( 'wpdo_create_snapshot' ); ?>
<button type="submit" class="button button-small"><?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?></button>
</form>
<a href="<?php echo esc_url( $page_url ); ?>" class="wpdo-widget-link-right">
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
</a>
</div>
<?php
}
// ─── private ──────────────────────────────────────────────────────
/**
* Compute widget status. Cached via transient.
*
* @return array {level:string, summary_msg:string, streak:int,
* last_snapshot:string, largest_zone:string,
* conflicts:int, postmeta_human:string}
*/
private static function compute_status(): array {
$cached = get_transient( 'wpdo_dashboard_widget_status' );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
// Health level from last cron run, falling back to "no data".
$last = class_exists( 'TMDO_Health_Cron' ) ? TMDO_Health_Cron::get_last_run() : null;
$level = 'unknown';
$msg = '';
$streak = 0;
if ( is_array( $last ) ) {
$crit = (int) ( $last['critical_count'] ?? 0 );
$rec = (int) ( $last['recommended_count'] ?? 0 );
if ( $crit > 0 ) {
$level = 'critical';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 critical 警告', '2meet-data-optimizer' ), $crit );
} elseif ( $rec > 0 ) {
$level = 'warn';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 recommended 提示', '2meet-data-optimizer' ), $rec );
} else {
$level = 'good';
$streak = TMDO_Health_Cron::consecutive_green_days();
}
$msg .= ' · ' . sprintf(
/* translators: %s: timestamp */
__( '最後執行:%s', '2meet-data-optimizer' ),
(string) $last['ran_at']
);
} else {
$msg = __( '尚未執行過健康檢查', '2meet-data-optimizer' );
}
// Last snapshot age.
$snap_table = $wpdb->prefix . 'wpdo_snapshots';
$snap_exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap_table
)
);
$last_snapshot = __( '從未', '2meet-data-optimizer' );
if ( 1 === $snap_exists ) {
$ts = (string) $wpdb->get_var( "SELECT created_at FROM `{$snap_table}` ORDER BY created_at DESC LIMIT 1" ); // phpcs:ignore WordPress.DB
if ( '' !== $ts ) {
$diff = time() - strtotime( $ts . ' UTC' );
$last_snapshot = $diff < 0 ? $ts : self::human_time_diff( $diff );
}
}
// Largest zone table by row count (rough sample).
$largest = '—';
if ( 1 === $snap_exists ) {
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_warm`" ); // phpcs:ignore WordPress.DB
if ( $row_count > 0 ) {
$largest = sprintf( 'wpdo_warm (%s)', number_format_i18n( $row_count ) );
}
}
// Conflict count (cheap — uses cached summary).
$conflicts = 0;
if ( class_exists( 'TMDO_Conflict_Monitor' ) ) {
$summary = TMDO_Conflict_Monitor::get_summary();
$conflicts = (int) ( $summary['total'] ?? 0 );
}
// wp_postmeta size (informational).
$pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
$result = array(
'level' => $level,
'summary_msg' => $msg,
'streak' => $streak,
'last_snapshot' => $last_snapshot,
'largest_zone' => $largest,
'conflicts' => $conflicts,
'postmeta_human' => number_format_i18n( $pm_count ),
);
set_transient( 'wpdo_dashboard_widget_status', $result, self::CACHE_TTL );
return $result;
}
/**
* Map level → CSS class + label.
*
* @param string $level Level slug.
* @return array {class:string,label:string}
*/
private static function traffic_light( string $level ): array {
switch ( $level ) {
case 'good':
return array(
'class' => 'good',
'label' => __( '正常', '2meet-data-optimizer' ),
);
case 'warn':
return array(
'class' => 'warn',
'label' => __( '注意', '2meet-data-optimizer' ),
);
case 'critical':
return array(
'class' => 'crit',
'label' => __( '警告', '2meet-data-optimizer' ),
);
default:
return array(
'class' => 'gray',
'label' => __( '未知', '2meet-data-optimizer' ),
);
}
}
/**
* Human-readable time-diff (seconds → "5 minutes ago" style). Uses WP
* `human_time_diff` when available; falls back to simple math otherwise.
*
* @param int $seconds Seconds delta (>=0).
* @return string
*/
private static function human_time_diff( int $seconds ): string {
if ( $seconds < 60 ) {
return $seconds . 's';
}
if ( function_exists( 'human_time_diff' ) ) {
return sprintf(
/* translators: %s: human-readable time difference */
__( '%s 前', '2meet-data-optimizer' ),
human_time_diff( time() - $seconds, time() )
);
}
if ( $seconds < 3600 ) {
return floor( $seconds / 60 ) . 'm';
}
if ( $seconds < 86400 ) {
return floor( $seconds / 3600 ) . 'h';
}
return floor( $seconds / 86400 ) . 'd';
}
}