feat(widgets): rebuild admin widgets page to match reference theme

- Replace transfer modal/admin-post flow with reference-style AJAX
  export/import/template dialogs (admin-transfer.js, TransferController)
- Customizer "移出模块" action, reference lazy form loading with t-notice
- Common fields use reference labels, dependencies, compact/inline rows,
  top/bottom-only extra spacing; form header with description/reminder
- Remove placement hint, template preview and legacy transfer code
- Fix widget audit (current_datetime stub, honor catalog common flags)
  and regenerate stale golden fixtures for posts_toc, quote, posts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 14:00:57 +08:00
1 parent ea80cd96cc
commit a579a5b00a
14 files changed
+698 -446

No files matched your search

+84 -120
View File
@@ -4,156 +4,120 @@ namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/** Admin-only import, export, template application and preview. */
/** Admin AJAX endpoints for per-sidebar export, JSON import and template import. */
final class TransferController {
private const NONCE = 'aksrt_widgets_transfer';
private static bool $controls_rendered = false;
public const NONCE = 'aksrt_widgets_transfer';
public static function register_hooks(): void {
add_action('admin_notices', [self::class, 'render_panel']);
add_action('customize_controls_print_footer_scripts', [self::class, 'render_panel']);
add_action('admin_post_aksrt_widgets_export', [self::class, 'handle_export']);
add_action('admin_post_aksrt_widgets_import', [self::class, 'handle_import']);
add_action('admin_post_aksrt_widgets_template', [self::class, 'handle_template']);
add_action('wp_ajax_aksrt_widgets_template_preview', [self::class, 'ajax_preview']);
add_action('wp_ajax_aksrt_widgets_export_sidebar', [self::class, 'ajax_export']);
add_action('wp_ajax_aksrt_widgets_import_sidebar', [self::class, 'ajax_import']);
add_action('wp_ajax_aksrt_widgets_list_templates', [self::class, 'ajax_list_templates']);
add_action('wp_ajax_aksrt_widgets_import_template', [self::class, 'ajax_import_template']);
}
public static function render_panel(): void {
if (self::$controls_rendered) {
return;
}
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || !in_array($screen->id, ['widgets', 'customize'], true) || !current_user_can('edit_theme_options')) {
return;
}
self::$controls_rendered = true;
$templates = WidgetTransfer::templates();
$notice = isset($_GET['aksrt_widgets_notice']) ? sanitize_key(wp_unslash($_GET['aksrt_widgets_notice'])) : '';
if ($notice !== '') {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__('小工具配置已更新。', 'aksrt') . '</p></div>';
}
?>
<div class="aksrt-widget-transfer-modal" id="aksrt-widget-transfer-modal" aria-hidden="true" hidden>
<button type="button" class="aksrt-widget-transfer-modal__backdrop" data-aksrt-transfer-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"></button>
<section class="aksrt-widget-transfer-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="aksrt-widget-transfer-title">
<button type="button" class="aksrt-widget-transfer-modal__close" data-aksrt-transfer-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>">&times;</button>
<h2 id="aksrt-widget-transfer-title"><?php esc_html_e('小工具区域管理', 'aksrt'); ?></h2>
<p class="description aksrt-widget-transfer-modal__area"></p>
<nav class="aksrt-widget-transfer-modal__tabs" aria-label="<?php esc_attr_e('小工具操作', 'aksrt'); ?>">
<button type="button" class="button is-active" data-transfer-tab="export"><?php esc_html_e('导出', 'aksrt'); ?></button>
<button type="button" class="button" data-transfer-tab="import"><?php esc_html_e('导入', 'aksrt'); ?></button>
<button type="button" class="button" data-transfer-tab="template"><?php esc_html_e('内置模板', 'aksrt'); ?></button>
</nav>
<div class="aksrt-widget-transfer-modal__panel" data-transfer-panel="export">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="aksrt_widgets_export">
<?php wp_nonce_field(self::NONCE); ?>
<input type="hidden" name="sidebar" value="">
<p><?php esc_html_e('导出当前小工具区域的配置文件。', 'aksrt'); ?></p>
<?php submit_button(__('下载配置', 'aksrt'), 'primary', 'submit', false); ?>
</form>
</div>
<div class="aksrt-widget-transfer-modal__panel" data-transfer-panel="import" hidden>
<form method="post" enctype="multipart/form-data" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="aksrt_widgets_import">
<?php wp_nonce_field(self::NONCE); ?>
<input type="file" name="widgets_file" accept="application/json,.json" required>
<?php self::mode_select(); ?>
<input type="hidden" name="target_sidebar" value="">
<?php submit_button(__('导入配置', 'aksrt'), 'primary', 'submit', false); ?>
</form>
</div>
<div class="aksrt-widget-transfer-modal__panel" data-transfer-panel="template" hidden>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="aksrt_widgets_template">
<?php wp_nonce_field(self::NONCE); ?>
<select name="template" class="aksrt-widget-template-select" aria-label="<?php esc_attr_e('选择内置模板', 'aksrt'); ?>">
<?php foreach ($templates as $key => $template) : ?>
<option value="<?php echo esc_attr($key); ?>"><?php echo esc_html((string) $template['label']); ?></option>
<?php endforeach; ?>
</select>
<?php self::mode_select(); ?>
<input type="hidden" name="target_sidebar" value="">
<div class="aksrt-widget-transfer__actions">
<button type="button" class="button aksrt-widget-template-preview"><?php esc_html_e('预览模块', 'aksrt'); ?></button>
<?php submit_button(__('应用模板', 'aksrt'), 'primary', 'submit', false); ?>
</div>
</form>
<div class="aksrt-widget-template-preview-output" hidden></div>
</div>
</section>
</div>
<?php
}
public static function handle_export(): void {
public static function ajax_export(): void {
self::authorize();
$sidebar = isset($_POST['sidebar']) ? sanitize_key(wp_unslash($_POST['sidebar'])) : '';
$payload = WidgetTransfer::export($sidebar);
nocache_headers();
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="aksrt-widgets-' . gmdate('Ymd-His') . '.json"');
echo wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
$sidebar = self::target_sidebar();
$json = wp_json_encode(WidgetTransfer::export($sidebar), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
wp_send_json_success(['json_text' => (string) $json, 'sidebar_id' => $sidebar]);
}
public static function handle_import(): void {
public static function ajax_import(): void {
self::authorize();
$file = $_FILES['widgets_file'] ?? null;
if (!is_array($file) || !empty($file['error']) || empty($file['tmp_name']) || (int) ($file['size'] ?? 0) > 2 * MB_IN_BYTES) {
wp_die(esc_html__('导入文件无效或超过 2MB。', 'aksrt'));
$sidebar = self::target_sidebar();
$raw = isset($_POST['json']) ? trim((string) wp_unslash($_POST['json'])) : '';
if ($raw === '') {
wp_send_json_error(['message' => __('请粘贴 JSON 内容。', 'aksrt')]);
}
$payload = json_decode((string) file_get_contents((string) $file['tmp_name']), true);
if (strlen($raw) > 2 * MB_IN_BYTES) {
wp_send_json_error(['message' => __('JSON 内容超过 2MB。', 'aksrt')]);
}
$payload = json_decode($raw, true);
if (!is_array($payload)) {
wp_die(esc_html__('导入文件不是有效 JSON。', 'aksrt'));
wp_send_json_error(['message' => __('JSON 解析失败,请检查格式。', 'aksrt')]);
}
self::run_import($payload);
$mode = self::import_mode();
$result = self::run_import($payload, $sidebar, $mode);
$message = $mode === 'append'
/* translators: 1: sidebar name, 2: imported widget count */
? __('已向侧栏「%1$s」末尾追加 %2$d 个小工具。请刷新页面后查看效果。', 'aksrt')
/* translators: 1: sidebar name, 2: imported widget count */
: __('已覆盖侧栏「%1$s」并导入 %2$d 个小工具。请刷新页面后查看效果。', 'aksrt');
self::send_result($sidebar, $mode, $result, $message);
}
public static function handle_template(): void {
public static function ajax_list_templates(): void {
self::authorize();
$template = isset($_POST['template']) ? sanitize_key(wp_unslash($_POST['template'])) : '';
$list = [];
foreach (WidgetTransfer::templates() as $id => $template) {
$list[] = [
'id' => (string) $id,
'title' => (string) $template['title'],
'desc' => (string) ($template['desc'] ?? ''),
'remind' => (string) ($template['remind'] ?? ''),
'modules' => WidgetTransfer::template_modules((string) $id),
];
}
wp_send_json_success(['templates' => $list]);
}
public static function ajax_import_template(): void {
self::authorize();
$sidebar = self::target_sidebar();
$template = isset($_POST['template_id']) ? sanitize_key(wp_unslash($_POST['template_id'])) : '';
try {
$payload = WidgetTransfer::template_payload($template);
} catch (\InvalidArgumentException $error) {
wp_die(esc_html($error->getMessage()), '', ['response' => 400]);
}
self::run_import($payload);
}
public static function ajax_preview(): void {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => __('权限不足。', 'aksrt')], 403);
}
$template = isset($_POST['template']) ? sanitize_key(wp_unslash($_POST['template'])) : '';
try {
wp_send_json_success(['html' => WidgetTransfer::preview($template)]);
} catch (\InvalidArgumentException $error) {
wp_send_json_error(['message' => $error->getMessage()], 400);
wp_send_json_error(['message' => $error->getMessage()]);
}
$mode = self::import_mode();
$result = self::run_import($payload, $sidebar, $mode);
$message = $mode === 'append'
/* translators: 1: sidebar name, 2: imported widget count */
? __('已从模板向侧栏「%1$s」末尾追加 %2$d 个小工具。请刷新页面后查看效果。', 'aksrt')
/* translators: 1: sidebar name, 2: imported widget count */
: __('已用模板覆盖侧栏「%1$s」并导入 %2$d 个小工具。请刷新页面后查看效果。', 'aksrt');
self::send_result($sidebar, $mode, $result, $message, ['template_id' => $template]);
}
private static function authorize(): void {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('edit_theme_options')) {
wp_die(esc_html__('权限不足。', 'aksrt'), '', ['response' => 403]);
wp_send_json_error(['message' => __('权限不足', 'aksrt')], 403);
}
check_admin_referer(self::NONCE);
}
private static function run_import(array $payload): void {
$mode = isset($_POST['mode']) ? sanitize_key(wp_unslash($_POST['mode'])) : 'append';
$target = isset($_POST['target_sidebar']) ? sanitize_key(wp_unslash($_POST['target_sidebar'])) : '';
private static function target_sidebar(): string {
$sidebar = isset($_POST['sidebar_id']) ? sanitize_key(wp_unslash($_POST['sidebar_id'])) : '';
if ($sidebar === '' || !isset($GLOBALS['wp_registered_sidebars'][$sidebar])) {
wp_send_json_error(['message' => __('无效的侧栏。', 'aksrt')]);
}
return $sidebar;
}
private static function import_mode(): string {
$mode = isset($_POST['import_mode']) ? sanitize_key(wp_unslash($_POST['import_mode'])) : 'append';
return $mode === 'replace' ? 'replace' : 'append';
}
/** @return array{sidebars:int,widgets:int} */
private static function run_import(array $payload, string $sidebar, string $mode): array {
try {
WidgetTransfer::import($payload, $mode, $target);
return WidgetTransfer::import($payload, $mode, $sidebar);
} catch (\InvalidArgumentException $error) {
wp_die(esc_html($error->getMessage()), '', ['response' => 400]);
wp_send_json_error(['message' => $error->getMessage()]);
}
wp_safe_redirect(add_query_arg('aksrt_widgets_notice', 'updated', admin_url('widgets.php')));
exit;
}
private static function mode_select(): void {
echo '<select name="mode"><option value="append">' . esc_html__('追加', 'aksrt') . '</option><option value="replace">' . esc_html__('替换区域', 'aksrt') . '</option></select>';
/** @param array{sidebars:int,widgets:int} $result */
private static function send_result(string $sidebar, string $mode, array $result, string $message, array $extra = []): void {
$name = (string) ($GLOBALS['wp_registered_sidebars'][$sidebar]['name'] ?? $sidebar);
wp_send_json_success(array_merge([
'imported' => (int) $result['widgets'],
'sidebar_id' => $sidebar,
'import_mode' => $mode,
'warnings' => [],
'message' => sprintf($message, $name, (int) $result['widgets']),
], $extra));
}
}