fix(frontend): 修复前台页面编辑弹窗并 1:1 复刻参考主题

远端加载的弹窗在 admin-ajax 上下文里拿不到查询对象,enabled()
恒为假,直接返回 success:false;而 AksrtRequest.responseError
只读 message/data.message,读不到 msg,遂报「请求失败(HTTP 200)」。
改为参考主题原生架构:wp_footer 直出 #modal_admin_set,提交走
vendored main.js 的 aksrt_ajax 契约,不再有 AJAX 取 HTML。

对齐参考主题 zib-frontend-set.php:
- 弹窗补齐 aria-hidden=false、win-ajax-replace、role=document
- 隐藏字段改用 taxonomy + post_type
- 字段顺序与 is_page 分支(标题样式、widgets_register、容器位置)
- 多选 checkbox 改 name="id[]",描述允许富文本
- 模块布局指向定制器小工具面板
- term 保存使用 POST 的 taxonomy 字段

清理不再使用的 assets/js/page-admin-set.js。
新增 frontend-page-edit-harness.php 覆盖门禁、渲染与各保存分支。
This commit is contained in:
2026-10-06 12:22:43 +08:00
parent b75bf197a3
commit 12f60897d9
4 changed files with 630 additions and 207 deletions
@@ -0,0 +1,260 @@
<?php
/** Frontend page edit runtime harness: modal gating, input rendering, and ajax-save outside the query. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
$GLOBALS['aksrt_set_settings'] = [];
$GLOBALS['aksrt_set_meta'] = [];
$GLOBALS['aksrt_set_json'] = null;
$GLOBALS['aksrt_set_output'] = '';
$GLOBALS['aksrt_set_term'] = null;
$GLOBALS['aksrt_set_post'] = null;
$GLOBALS['aksrt_set_queried'] = 0;
$GLOBALS['aksrt_set_post_type'] = 'post';
$GLOBALS['aksrt_set_is_single'] = false;
$GLOBALS['aksrt_set_is_page'] = false;
$GLOBALS['aksrt_set_is_category'] = false;
$GLOBALS['aksrt_set_object'] = null;
$GLOBALS['aksrt_set_page_templates'] = [];
$GLOBALS['aksrt_set_can'] = true;
function __(string $text, string $domain = ''): string { return $text; }
function esc_html__($t, $d = ''): string { return (string) $t; }
function esc_attr__($t, $d = ''): string { return (string) $t; }
function esc_html($t): string { return (string) $t; }
function esc_attr($t): string { return (string) $t; }
function esc_url($t): string { return (string) $t; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function sanitize_text_field($v): string { return trim(strip_tags((string) $v)); }
function sanitize_textarea_field($v): string { return trim(strip_tags((string) $v)); }
function wp_unslash($v) { return $v; }
function absint($v): int { return max(0, (int) $v); }
function is_user_logged_in(): bool { return true; }
function current_user_can($cap, ...$args): bool { return (bool) $GLOBALS['aksrt_set_can']; }
function is_single(): bool { return $GLOBALS['aksrt_set_is_single']; }
function is_page(): bool { return $GLOBALS['aksrt_set_is_page']; }
function is_category(): bool { return $GLOBALS['aksrt_set_is_category']; }
function is_tag(): bool { return false; }
function is_tax(): bool { return false; }
function get_queried_object_id(): int { return $GLOBALS['aksrt_set_queried']; }
function get_queried_object() { return $GLOBALS['aksrt_set_object']; }
function get_post_type(): string { return $GLOBALS['aksrt_set_post_type']; }
function get_post($id) { return $GLOBALS['aksrt_set_post']; }
function get_post_meta($id, $key = '', $single = false) { return $GLOBALS['aksrt_set_meta'][$key] ?? ''; }
function update_post_meta($id, $key, $value): void { $GLOBALS['aksrt_set_meta'][$key] = $value; }
function get_the_title($id): string { return '示例标题'; }
function get_the_permalink($id): string { return 'https://example.test/post'; }
function get_post_format($id) { return ''; }
function set_post_format($id, $format): void { $GLOBALS['aksrt_set_meta']['__format'] = $format; }
function post_type_supports($type, $feature): bool { return $feature === 'post-formats'; }
function get_theme_support($feature) { return $feature === 'post-formats' ? [['image', 'video']] : false; }
function get_post_format_string($format): string { return ucfirst((string) $format); }
function comments_open($id): bool { return true; }
function get_edit_post_link($id, $context = '') { return 'https://example.test/wp-admin/post.php'; }
function get_edit_term_link($id, $tax = '') { return 'https://example.test/wp-admin/term.php'; }
function get_taxonomy($tax) { return (object) ['labels' => (object) ['singular_name' => '分类']]; }
function get_post_type_object($type) { return (object) ['labels' => (object) ['singular_name' => $type === 'page' ? '页面' : '文章']]; }
function get_term($id, $tax = '') { return $GLOBALS['aksrt_set_term']; }
function wp_update_term($id, $tax, $args) { $GLOBALS['aksrt_set_meta']['__term'] = $args; return 1; }
function wp_update_post($args, $wp_error = false) { $GLOBALS['aksrt_set_meta']['__post'] = $args; return 1; }
function is_wp_error($v): bool { return false; }
function wp_is_mobile(): bool { return false; }
function is_page_template($tpl): bool { return in_array($tpl, $GLOBALS['aksrt_set_page_templates'], true); }
function home_url($path = ''): string { return 'https://example.test' . $path; }
function add_query_arg($args, $url = null) {
if ($args === null) { return 'https://example.test/current'; }
if ($url === null) { $url = 'https://example.test/current'; }
$query = is_array($args) ? http_build_query($args) : (string) $args;
return $url . (strpos($url, '?') === false ? '?' : '&') . $query;
}
function admin_url($path = ''): string { return 'https://example.test/wp-admin/' . ltrim((string) $path, '/'); }
function wp_nonce_field($action = -1, $name = '_wpnonce', $referer = true, $echo = true) {
$html = '<input type="hidden" name="' . $name . '" value="nonce">';
if ($echo) { echo $html; return null; }
return $html;
}
function check_ajax_referer($action = -1, $name = '_wpnonce'): bool { return true; }
function wp_send_json($data, $code = null): void {
$GLOBALS['aksrt_set_json'] = $data;
throw new \RuntimeException('__sent__');
}
function wp_kses($t, $allowed = null): string { return (string) $t; }
function checked($a, $b = true, $echo = true): string { return (string) $a === (string) $b ? ' checked="checked"' : ''; }
function selected($a, $b = true, $echo = true): string { return (string) $a === (string) $b ? ' selected="selected"' : ''; }
function set_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
}
namespace AKSRT\Settings {
final class ExtensionRuntime {
public static array $settings = [];
public static function get_system_tools_settings(): array { return self::$settings; }
}
}
namespace AKSRT\Core {
final class PostMetaBox {
public const META_KEY = 'aksrt_post_extension';
public static function get_meta(int $post_id): array { return $GLOBALS['aksrt_set_meta']; }
}
final class DomainModel {
public const POST_VIEW_COUNT_KEY = 'aksrt_post_views';
}
}
namespace {
use AKSRT\Frontend\PageAdminSet;
require dirname(__DIR__, 2) . '/src/Frontend/PageAdminSet.php';
\AKSRT\Settings\ExtensionRuntime::$settings = ['admin_frontend_set' => true];
$GLOBALS['aksrt_set_queried'] = 0;
$GLOBALS['aksrt_set_is_single'] = true;
// --- Gate: a non-admin-visible context must not render anything. ---
set_check(PageAdminSet::enabled() === false, 'enabled() must be false without a queried object');
$GLOBALS['aksrt_set_queried'] = 12;
set_check(PageAdminSet::enabled() === true, 'enabled() must be true on a single post for an admin');
$GLOBALS['aksrt_set_can'] = false;
set_check(PageAdminSet::enabled() === false, 'enabled() must respect manage_options');
$GLOBALS['aksrt_set_can'] = true;
\AKSRT\Settings\ExtensionRuntime::$settings = ['admin_frontend_set' => false];
set_check(PageAdminSet::enabled() === false, 'enabled() must respect the admin_frontend_set switch');
\AKSRT\Settings\ExtensionRuntime::$settings = ['admin_frontend_set' => true];
$GLOBALS['aksrt_set_page_templates'] = ['templates/pages/page-submit.php'];
set_check(PageAdminSet::enabled() === false, 'enabled() must exclude the submit page template');
$GLOBALS['aksrt_set_page_templates'] = [];
$GLOBALS['aksrt_set_post'] = (object) ['ID' => 12, 'post_type' => 'post'];
// --- Modal: reference markup parity. ---
ob_start();
(new PageAdminSet())->render_modal();
$modal = (string) ob_get_clean();
set_check(strpos($modal, 'id="modal_admin_set"') !== false, 'modal must render #modal_admin_set');
set_check(strpos($modal, 'win-ajax-replace="modal_admin_set"') !== false, 'modal must carry the reference win-ajax-replace key');
set_check(strpos($modal, 'aria-hidden="false"') !== false, 'modal must expose aria-hidden="false"');
set_check(strpos($modal, 'class="modal-content page-set-modal"') !== false, 'modal must keep the page-set-modal class');
set_check(strpos($modal, 'class="modal-dialog" role="document"') !== false, 'modal must keep the reference dialog/document roles');
set_check(strpos($modal, 'data-dismiss="modal"') !== false, 'modal header must expose the dismiss button');
set_check(strpos($modal, 'name="action" value="aksrt_frontend_set_save"') !== false, 'modal must post aksrt_frontend_set_save');
set_check(strpos($modal, 'name="taxonomy" value=""') !== false, 'modal must post the taxonomy field');
set_check(strpos($modal, 'name="post_type" value="post"') !== false, 'modal must post the post_type field');
set_check(strpos($modal, 'name="id" value="12"') !== false, 'modal must post the object id');
set_check(strpos($modal, 'customize.php') !== false, '模块布局 must target the customizer');
set_check(strpos($modal, 'wp-ajax-submit') !== false, 'modal footer must submit via wp-ajax-submit');
set_check(strpos($modal, 'but jb-blue padding-lg') !== false, 'submit button must keep reference classes');
set_check(strpos($modal, 'but c-yellow padding-lg') !== false, '后台编辑 must keep reference classes');
set_check(strpos($modal, 'but c-yellow-2 padding-lg') !== false, '模块布局 must keep reference classes');
set_check(strpos($modal, 'col-xs-3 text-right') !== false, 'inputs must use the reference heading column');
set_check(strpos($modal, 'col-xs-8') !== false, 'inputs must use the reference option column');
set_check(strpos($modal, 'name="show_layout"') !== false, 'post inputs must include show_layout');
set_check(strpos($modal, 'name="no_article-navs"') !== false, 'post inputs must include the 目录树 checkbox');
set_check(strpos($modal, 'value="checkbox"') === false, 'checkbox inputs must render type="checkbox" not value="checkbox"');
// --- Save: the reference contract returns {msg, reload} outside any query context. ---
$_POST = [
'id' => '12',
'type' => 'post',
'post_title' => '新标题',
'comments_open' => '',
'show_layout' => 'no_sidebar',
'subtitle' => '副标题',
'views' => '42',
'like' => '7',
'no_article-navs' => '1',
];
try {
(new PageAdminSet())->ajax_save();
set_check(false, 'ajax_save must terminate the request');
} catch (\RuntimeException $e) {
set_check($e->getMessage() === '__sent__', 'ajax_save must terminate via wp_send_json');
}
set_check(is_array($GLOBALS['aksrt_set_json']), 'save must send a JSON payload');
set_check(($GLOBALS['aksrt_set_json']['msg'] ?? '') === '保存成功,正在刷新页面', 'save must reuse the reference success message');
set_check(!empty($GLOBALS['aksrt_set_json']['reload']), 'save must ask the client to reload');
set_check(($GLOBALS['aksrt_set_meta']['__post']['post_title'] ?? '') === '新标题', 'save must update the post title');
set_check(($GLOBALS['aksrt_set_meta']['__post']['comment_status'] ?? '') === 'closed', 'save must close comments when empty');
$saved_meta = $GLOBALS['aksrt_set_meta']['aksrt_post_extension'] ?? [];
set_check(($saved_meta['show_layout'] ?? '') === 'no_sidebar', 'save must persist show_layout');
set_check(($saved_meta['subtitle'] ?? '') === '副标题', 'save must persist the subtitle');
set_check(!empty($saved_meta['no_article-navs']), 'checked checkbox must persist as on');
set_check(empty($saved_meta['article_maxheight_xz']), 'unchecked checkbox must persist as off');
set_check((int) $GLOBALS['aksrt_set_meta']['aksrt_post_views'] === 42, 'save must persist the view count');
set_check((int) $GLOBALS['aksrt_set_meta']['_aksrt_like_count'] === 7, 'save must persist the like count');
// --- Permission: the switch off must reject the save even with a valid object. ---
\AKSRT\Settings\ExtensionRuntime::$settings = ['admin_frontend_set' => false];
$GLOBALS['aksrt_set_json'] = null;
try {
(new PageAdminSet())->ajax_save();
} catch (\RuntimeException $e) {
// expected
}
set_check(($GLOBALS['aksrt_set_json']['error'] ?? 0) === 1, 'disabled switch must reject the save');
set_check(!empty($GLOBALS['aksrt_set_json']['ys']), 'rejection must carry the danger type for the legacy client');
// --- Term save path. ---
\AKSRT\Settings\ExtensionRuntime::$settings = ['admin_frontend_set' => true];
$GLOBALS['aksrt_set_json'] = null;
$GLOBALS['aksrt_set_meta'] = [];
$GLOBALS['aksrt_set_term'] = (object) ['term_id' => 5, 'taxonomy' => 'category', 'name' => '旧名称', 'description' => ''];
$_POST = ['id' => '5', 'type' => 'tax', 'taxonomy' => 'category', 'term_name' => '新分类', 'term_description' => '描述'];
try {
(new PageAdminSet())->ajax_save();
} catch (\RuntimeException $e) {
// expected
}
set_check(($GLOBALS['aksrt_set_meta']['__term']['name'] ?? '') === '新分类', 'term save must update the name');
set_check(($GLOBALS['aksrt_set_meta']['__term']['description'] ?? '') === '描述', 'term save must update the description');
// --- Page branch: 标题样式 + 模块布局 + 容器位置. ---
$GLOBALS['aksrt_set_queried'] = 21;
$GLOBALS['aksrt_set_is_single'] = false;
$GLOBALS['aksrt_set_is_page'] = true;
$GLOBALS['aksrt_set_post_type'] = 'page';
$GLOBALS['aksrt_set_post'] = (object) ['ID' => 21, 'post_type' => 'page'];
$GLOBALS['aksrt_set_json'] = null;
$GLOBALS['aksrt_set_meta'] = [];
$_POST = [
'id' => '21',
'type' => 'post',
'post_title' => '页面标题',
'page_header_style' => '2',
'widgets_register' => '1',
'widgets_register_container' => ['sidebar', 'top_fluid'],
];
try {
(new PageAdminSet())->ajax_save();
} catch (\RuntimeException $e) {
// expected
}
set_check(($GLOBALS['aksrt_set_meta']['page_header_style'] ?? '') === '2', 'page save must persist the header style');
set_check(($GLOBALS['aksrt_set_meta']['widgets_register'] ?? '') === '1', 'page save must enable the widget container');
set_check(($GLOBALS['aksrt_set_meta']['widgets_register_container'] ?? []) === ['sidebar', 'top_fluid'], 'page save must persist container positions');
$GLOBALS['aksrt_set_json'] = null;
$GLOBALS['aksrt_set_meta'] = [];
$_POST = ['id' => '21', 'type' => 'post', 'post_title' => '页面标题', 'widgets_register' => '0'];
try {
(new PageAdminSet())->ajax_save();
} catch (\RuntimeException $e) {
// expected
}
set_check(($GLOBALS['aksrt_set_meta']['widgets_register'] ?? '') === '', 'closing the widget container must clear the flag');
set_check(($GLOBALS['aksrt_set_meta']['widgets_register_container'] ?? ['x']) === [], 'closing the widget container must clear positions');
echo "Frontend page edit harness passed\n";
}
@@ -150,11 +150,11 @@ test('split upload ships a full chunk protocol: routes, merge pipeline, and clie
assert.doesNotMatch(read('src/Core/ScriptsPart1c.php'), /is_split_upload|split_minimum_size/);
});
test('frontend page edit ships gate, float button, modal, and whitelisted save', () => {
test('frontend page edit ships gate, footer modal, and legacy ajax-save contract', () => {
const pageAdminSet = read('src/Frontend/PageAdminSet.php');
const pageAdminJs = read('assets/js/page-admin-set.js');
const floatButtons = read('inc/helpers/aksrt-float-buttons.php');
const bootstrap = read('functions.php');
const mainJs = read('assets/js/runtime/main.js');
assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/);
assert.match(pageAdminSet, /public static function enabled\(\): bool/);
@@ -163,27 +163,59 @@ test('frontend page edit ships gate, float button, modal, and whitelisted save',
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-submit\.php'\)/);
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-posts-navs\.php'\)/);
// Float bar gains the same extension point the reference theme hooks into.
// Reference parity: the gear float button opens the footer-rendered bootstrap modal.
assert.match(floatButtons, /apply_filters\('aksrt_float_right_buttons', \$btn, \$is_mobile\)/);
assert.match(pageAdminSet, /add_filter\('aksrt_float_right_buttons', \[\$this, 'float_button'\], 10, 2\)/);
assert.match(pageAdminSet, /data-aksrt-modal="remote"/);
assert.match(pageAdminSet, /check_ajax_referer\('aksrt_frontend_set', '_wpnonce'\)/);
assert.match(pageAdminSet, /add_action\('wp_footer', \[\$this, 'render_modal'\], 10\)/);
assert.match(pageAdminSet, /data-toggle="modal" data-target="#modal_admin_set"/);
assert.match(pageAdminSet, /class="modal fade" id="modal_admin_set" tabindex="-1" role="dialog" aria-hidden="false" win-ajax-replace="modal_admin_set"/);
assert.match(pageAdminSet, /class="modal-content page-set-modal"/);
assert.match(pageAdminSet, /wp_nonce_field\('aksrt_frontend_set', '_wpnonce', true, false\)/);
// Reference hidden inputs: type + taxonomy + post_type + id travel with the submit.
assert.match(pageAdminSet, /name="action" value="aksrt_frontend_set_save"/);
assert.match(pageAdminSet, /name="type" value="' . esc_attr\(\$type\)/);
assert.match(pageAdminSet, /name="taxonomy" value="' . esc_attr\(\$taxonomy\)/);
assert.match(pageAdminSet, /name="post_type" value="' . esc_attr\(\$post_type\)/);
// 模块布局 opens the customizer widgets panel and returns to the current URL.
assert.match(pageAdminSet, /admin_url\('customize\.php'\)/);
assert.match(pageAdminSet, /'autofocus' => \['panel' => 'widgets'\]/);
// Modal + save endpoints; save is whitelisted and preserves other meta keys.
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_modal', \[\$this, 'ajax_modal'\]\)/);
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_save', \[\$this, 'ajax_save'\]\)/);
// Modal footer mirrors the reference: 模块布局 / 后台编辑 / 确认修改 (wp-ajax-submit).
assert.match(pageAdminSet, /esc_html__\('模块布局', 'aksrt'\)/);
assert.match(pageAdminSet, /esc_html__\('后台编辑', 'aksrt'\)/);
assert.match(pageAdminSet, /esc_html__\('确认修改', 'aksrt'\)/);
assert.match(pageAdminSet, /class="but c-yellow-2 padding-lg"/);
assert.match(pageAdminSet, /class="but c-yellow padding-lg"/);
assert.match(pageAdminSet, /class="but jb-blue padding-lg wp-ajax-submit"/);
// Fields replicate the reference input construct and map to AKSRT's post meta.
assert.match(pageAdminSet, /private function render_inputs\(array \$inputs\): string/);
assert.match(pageAdminSet, /class="heading col-xs-3 text-right"/);
assert.match(pageAdminSet, /class="option col-xs-8"/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\PostMetaBox::META_KEY/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/);
assert.match(pageAdminSet, /update_post_meta\(\$post_id, '_aksrt_like_count', max\(0, \(int\) \$_POST\['like'\]\)\)/);
assert.match(pageAdminSet, /wp_update_term\(\$term_id, \(string\) \$term->taxonomy, \$data\)/);
assert.match(pageAdminSet, /set_post_format\(\$post_id, \$format === 'standard' \? '' : \$format\)/);
// Page branch: 标题样式 + 模块布局(widgets_register) + 容器位置.
assert.match(pageAdminSet, /esc_attr\(\(string\) \(\$meta\['id'\] \?\? ''\)\) \. '\[\]" id="'/);
assert.match(pageAdminSet, /'page_header_style'/);
assert.match(pageAdminSet, /update_post_meta\(\$post_id, 'widgets_register', \$register \? '1' : ''\)/);
assert.match(pageAdminSet, /update_post_meta\(\$post_id, 'widgets_register_container', \$containers\)/);
// Reference checkbox semantics: unchecked boxes never submit, so both are written unconditionally.
assert.match(pageAdminSet, /\$meta\['no_article-navs'\] = !empty\(\$_POST\['no_article-navs'\]\)/);
assert.match(pageAdminSet, /\$meta\['article_maxheight_xz'\] = !empty\(\$_POST\['article_maxheight_xz'\]\)/);
// The geometry mirrors the reference construct (row + heading + option, \n separators).
assert.match(pageAdminSet, /'<div class="mb10 row' \. esc_attr\(\$class\) \. '">' \. "\\n"/);
// Client submits through the shared modal runtime contract; the ajax action
// comes from the form's hidden field that the modal endpoint embeds.
assert.match(pageAdminJs, /form\.aksrt-admin-set-form/);
assert.match(pageAdminJs, /input\[name="action"\]/);
assert.match(pageAdminJs, /window\.location\.reload/);
assert.match(pageAdminSet, /'html' => \$html/);
// Save runs under the vendored main.js aksrt_ajax contract ({msg,reload}).
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_save', \[\$this, 'ajax_save'\]\)/);
assert.match(pageAdminSet, /check_ajax_referer\('aksrt_frontend_set', '_wpnonce'\)/);
assert.match(pageAdminSet, /wp_send_json\(\['msg' => __\('保存成功,正在刷新页面', 'aksrt'\), 'reload' => true\]\)/);
assert.match(mainJs, /jQuery\.fn\.serializeObject = function/);
assert.match(mainJs, /if \(n\.reload\) \{/);
// The dedicated fetch-submit client is gone: vendored main.js owns the flow.
assert.equal(fs.existsSync(path.join(root, 'assets/js/page-admin-set.js')), false);
});
test('float toolbar config reaches the tinymce plugin with reference-context buttons', () => {
@@ -227,3 +259,9 @@ test('split upload runtime contract harness passes', () => {
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Split upload harness passed/);
});
test('frontend page edit runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'frontend-page-edit-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Frontend page edit harness passed/);
});