diff --git a/assets/js/page-admin-set.js b/assets/js/page-admin-set.js deleted file mode 100644 index 05d8c41f..00000000 --- a/assets/js/page-admin-set.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * AKSRT Page Admin Set — 前端页面编辑弹窗提交(后台「扩展&增强 → 系统工具」)。 - * - * 弹窗内容由 PageAdminSet::ajax_modal() 经 aksrt-modal-runtime 注入, - * 这里只负责表单提交:序列化 → admin-ajax → 成功后刷新页面。 - * - * @package AKSRT - */ -(function (window, document) { - 'use strict'; - - function notify(message, type) { - if (typeof window.notyf === 'function') { - window.notyf(message, type || 'error', 4, 'aksrt-page-admin-set-toast'); - } - } - - document.addEventListener('submit', function (event) { - var form = event.target.closest('form.aksrt-admin-set-form'); - if (!form) return; - event.preventDefault(); - - var submitButton = form.querySelector('.aksrt-admin-set-submit'); - if (submitButton && submitButton.disabled) return; - if (submitButton) submitButton.disabled = true; - - var action = (form.querySelector('input[name="action"]') || {}).value || ''; - var ajaxUrl = (window.aksrtData && window.aksrtData.ajaxUrl) || (window._win && window._win.ajax_url) || ''; - if (!ajaxUrl) { - notify('请求地址缺失', 'error'); - if (submitButton) submitButton.disabled = false; - return; - } - - var url = ajaxUrl + (ajaxUrl.indexOf('?') === -1 ? '?' : '&') + 'action=' + encodeURIComponent(action); - fetch(url, { - method: 'POST', - credentials: 'same-origin', - body: new FormData(form), - }) - .then(function (response) { - return response.json().catch(function () { return {}; }).then(function (payload) { - if (!response.ok || !payload || !payload.success) { - throw new Error((payload && payload.data && payload.data.msg) || '保存失败,请重试'); - } - return payload; - }); - }) - .then(function (payload) { - var data = payload.data || {}; - notify(data.msg || '保存成功', 'success'); - if (data.reload !== false) { - window.setTimeout(function () { window.location.reload(); }, 600); - } else if (submitButton) { - submitButton.disabled = false; - } - }) - .catch(function (error) { - notify(error.message || '保存失败,请重试', 'error'); - if (submitButton) submitButton.disabled = false; - }); - }); -})(window, document); diff --git a/src/Frontend/PageAdminSet.php b/src/Frontend/PageAdminSet.php index 1785937f..4a43f7b0 100644 --- a/src/Frontend/PageAdminSet.php +++ b/src/Frontend/PageAdminSet.php @@ -2,9 +2,9 @@ /** * 前端页面编辑(后台「扩展&增强 → 系统工具」的 admin_frontend_set)。 * - * 对齐参考主题 zib-frontend-set.php:管理员在前台通过悬浮按钮打开弹窗, - * 快速修改当前文章/页面的标题、副标题、布局、计数、评论开关或分类术语的 - * 名称与描述;保存走白名单,行为与后台 PostMetaBox / wp_update_term 一致。 + * 对齐参考主题 zib-frontend-set.php:wp_footer 直出 #modal_admin_set 弹窗 + * (bootstrap modal + wp-ajax-submit 走 vendored main.js 的 aksrt_ajax 契约), + * 管理员在前台快速修改当前文章/页面的参数或分类术语的名称与描述。 * * @package AKSRT */ @@ -26,9 +26,8 @@ class PageAdminSet { public function init(): void { add_filter('aksrt_float_right_buttons', [$this, 'float_button'], 10, 2); - add_action('wp_ajax_aksrt_frontend_set_modal', [$this, 'ajax_modal']); + add_action('wp_footer', [$this, 'render_modal'], 10); add_action('wp_ajax_aksrt_frontend_set_save', [$this, 'ajax_save']); - add_action('wp_enqueue_scripts', [$this, 'enqueue_script']); } /** 开启条件:设置开关 + 管理员 + 单页/术语页 + 有效对象(排除投稿与导航页模板)。 */ @@ -49,91 +48,119 @@ class PageAdminSet { return true; } - /** 悬浮按钮:注入右侧悬浮按钮栏(对齐参考主题 zib_admin_frontend_set_botton)。 */ - public function float_button($buttons, $is_mobile) { + /** + * 悬浮按钮:追加到右侧悬浮按钮栏(对齐参考主题 zib_admin_frontend_set_botton)。 + * + * 参考主题对移动端与桌面端共用同一按钮,$is_mobile 仅用于保持 filter 签名。 + */ + public function float_button($buttons, $is_mobile = null) { if (!self::enabled()) { return $buttons; } - $url = add_query_arg( - [ - 'action' => 'aksrt_frontend_set_modal', - '_wpnonce' => wp_create_nonce('aksrt_frontend_set'), - ], - admin_url('admin-ajax.php') - ); + $button = '' + . '' + . '
' . esc_html__('在此编辑此页面参数', 'aksrt') . '
'; - $button = ''; - - return $button . (string) $buttons; + return (string) $buttons . $button; } - /** 条件加载弹窗提交脚本。 */ - public function enqueue_script(): void { + /** wp_footer 输出 #modal_admin_set(对齐参考主题 zib_frontend_set_modal)。 */ + public function render_modal(): void { if (!self::enabled()) { return; } - $src = get_template_directory() . '/assets/js/page-admin-set.js'; - wp_enqueue_script( - 'aksrt-page-admin-set', - get_template_directory_uri() . '/assets/js/page-admin-set.js', - [], - file_exists($src) ? (string) filemtime($src) : null, - true - ); - } - - /** GET 弹窗内容(注入 aksrt-modal-runtime)。 */ - public function ajax_modal(): void { - check_ajax_referer('aksrt_frontend_set', '_wpnonce'); - if (!self::enabled()) { - wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); - } $object_id = (int) get_queried_object_id(); if (is_category() || is_tag() || is_tax()) { - $html = $this->term_modal_html($object_id, (string) get_queried_object()->taxonomy); + $type = 'tax'; + $taxonomy = (string) get_queried_object()->taxonomy; + $post_type = ''; + $edit_link = get_edit_term_link($object_id, $taxonomy); + $type_label = (string) get_taxonomy($taxonomy)->labels->singular_name; } else { - $html = $this->post_modal_html($object_id, get_post_type($object_id) ?: 'post'); + $type = 'post'; + $taxonomy = ''; + $post_type = (string) get_post_type(); + $edit_link = (string) get_edit_post_link($object_id); + $type_label = (string) get_post_type_object($post_type)->labels->singular_name; } - wp_send_json_success(['html' => $html]); + // 对齐参考主题 zib_get_customize_widgets_url:跳转定制器小工具面板并返回当前页。 + $current_url = (string) home_url(add_query_arg(null, null)); + $widgets_url = add_query_arg([ + 'url' => rawurlencode($current_url), + 'autofocus' => ['panel' => 'widgets'], + 'return' => rawurlencode($current_url), + ], admin_url('customize.php')); + + $header = ''; + + $footer = ''; + + $body = ''; + + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 各片段已转义 } - /** POST 保存:仅接受白名单字段。 */ + /** 关闭按钮图标(本站 FA5 惯例,对齐参考主题 close 按钮位)。 */ + private function close_icon(): string { + return ''; + } + + /** 保存(legacy aksrt_ajax 契约:{msg, reload} / {error, ys, msg})。 */ public function ajax_save(): void { check_ajax_referer('aksrt_frontend_set', '_wpnonce'); - if (!self::enabled()) { - wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + + $settings = class_exists('\AKSRT\Settings\ExtensionRuntime') + ? \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings() + : []; + if (empty($settings['admin_frontend_set']) || !current_user_can('manage_options')) { + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('权限不足', 'aksrt')]); } $object_id = isset($_POST['id']) ? absint($_POST['id']) : 0; if (!$object_id) { - wp_send_json_error(['msg' => __('页面数据出错', 'aksrt')]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('页面数据出错', 'aksrt')]); } $type = isset($_POST['type']) ? sanitize_key((string) $_POST['type']) : ''; if ($type === 'tax') { - $taxonomy = isset($_POST['subtype']) ? sanitize_key((string) $_POST['subtype']) : ''; - $this->save_term($object_id, $taxonomy); - } else { + $this->save_term($object_id, isset($_POST['taxonomy']) ? sanitize_key((string) wp_unslash($_POST['taxonomy'])) : ''); + } elseif ($type === 'post') { $this->save_post($object_id); + } else { + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('页面数据出错', 'aksrt')]); } - wp_send_json_success(['msg' => __('保存成功,正在刷新页面', 'aksrt'), 'reload' => true]); + wp_send_json(['msg' => __('保存成功,正在刷新页面', 'aksrt'), 'reload' => true]); } private function save_post(int $post_id): void { $post = get_post($post_id); if (!$post) { - wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('未找到可用post数据', 'aksrt')]); } if (!current_user_can('edit_post', $post_id)) { - wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('权限不足', 'aksrt')]); } $postarr = ['ID' => $post_id]; @@ -144,12 +171,12 @@ class PageAdminSet { } } if (isset($_POST['comments_open'])) { - $postarr['comment_status'] = !empty($_POST['comments_open']) ? 'open' : 'closed'; + $postarr['comment_status'] = $_POST['comments_open'] === 'open' ? 'open' : 'closed'; } if (count($postarr) > 1) { $result = wp_update_post($postarr, true); if (is_wp_error($result)) { - wp_send_json_error(['msg' => $result->get_error_message()]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => $result->get_error_message()]); } } @@ -159,21 +186,21 @@ class PageAdminSet { } if ($post->post_type === 'post' && class_exists('\AKSRT\Core\PostMetaBox')) { - $layout = isset($_POST['show_layout']) ? sanitize_key((string) wp_unslash($_POST['show_layout'])) : ''; - if ($layout !== '' && !in_array($layout, ['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'], true)) { - $layout = 'false'; - } - // 保留白名单之外的既有配置键(封面、视频等),仅覆盖弹窗提交的字段。 - // 复选框未勾选时浏览器不会提交键名,必须无条件写入两个开关。 + // 复选框未勾选时浏览器不会提交键名,两个开关必须无条件写入。 $meta = get_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, true); $meta = is_array($meta) ? $meta : []; - if ($layout !== '') { - $meta['show_layout'] = $layout; + + $layout = isset($_POST['show_layout']) ? sanitize_key((string) wp_unslash($_POST['show_layout'])) : 'false'; + if (!in_array($layout, ['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'], true)) { + $layout = 'false'; } + $meta['show_layout'] = $layout; + if (isset($_POST['subtitle'])) { $meta['subtitle'] = sanitize_text_field(wp_unslash((string) $_POST['subtitle'])); } + $meta['no_article-navs'] = !empty($_POST['no_article-navs']); $meta['article_maxheight_xz'] = !empty($_POST['article_maxheight_xz']); update_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, $meta); @@ -185,15 +212,36 @@ class PageAdminSet { update_post_meta($post_id, '_aksrt_like_count', max(0, (int) $_POST['like'])); } } + + if ($post->post_type === 'page') { + if (isset($_POST['page_header_style'])) { + $style = sanitize_key((string) wp_unslash($_POST['page_header_style'])); + update_post_meta($post_id, 'page_header_style', in_array($style, ['', 'not', '1', '2', '3'], true) ? $style : ''); + } + + $register = isset($_POST['widgets_register']) && $_POST['widgets_register'] === '1'; + update_post_meta($post_id, 'widgets_register', $register ? '1' : ''); + + // 关闭小工具容器时清空容器位置;容器位置与参考主题的注册语义保持一致。 + $containers = []; + if ($register && !empty($_POST['widgets_register_container']) && is_array($_POST['widgets_register_container'])) { + $allowed = ['sidebar', 'top_fluid', 'top_content', 'bottom_content', 'bottom_fluid']; + $containers = array_values(array_unique(array_intersect( + array_map('sanitize_key', wp_unslash($_POST['widgets_register_container'])), + $allowed + ))); + } + update_post_meta($post_id, 'widgets_register_container', $containers); + } } private function save_term(int $term_id, string $taxonomy = ''): void { $term = $taxonomy !== '' ? get_term($term_id, $taxonomy) : get_term($term_id); if (!$term || is_wp_error($term)) { - wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('未找到可用term数据', 'aksrt')]); } if (!current_user_can('manage_categories') && !current_user_can('manage_terms')) { - wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => __('权限不足', 'aksrt')]); } $data = []; @@ -212,94 +260,234 @@ class PageAdminSet { $result = wp_update_term($term_id, (string) $term->taxonomy, $data); if (is_wp_error($result)) { - wp_send_json_error(['msg' => $result->get_error_message()]); + wp_send_json(['error' => 1, 'ys' => 'danger', 'msg' => $result->get_error_message()]); } } - private function post_modal_html(int $post_id, string $post_type): string { + /** 文章/页面的弹窗字段(对齐参考主题 zib_get_frontend_set_input)。 */ + private function post_inputs(int $post_id): string { $meta = class_exists('\AKSRT\Core\PostMetaBox') ? \AKSRT\Core\PostMetaBox::get_meta($post_id) : []; - $views = (int) ($meta['views'] ?? 0); - $like = (int) ($meta['like'] ?? 0); - $is_single = is_single(); + $inputs = []; - $html = '
'; - $html .= '

' . esc_html(get_the_title($post_id)) . '

'; + $inputs[] = [ + 'name' => __('显示布局', 'aksrt'), + 'id' => 'show_layout', + 'std' => (string) ($meta['show_layout'] ?? 'false'), + 'type' => 'radio', + 'options' => [ + 'false' => __('跟随主题', 'aksrt'), + 'no_sidebar' => __('无侧边栏', 'aksrt'), + 'sidebar_left' => __('侧边栏靠左', 'aksrt'), + 'sidebar_right' => __('侧边栏靠右', 'aksrt'), + ], + ]; - $html .= $this->text_field('post_title', __('标题', 'aksrt'), (string) get_the_title($post_id)); + $inputs[] = [ + 'name' => __('标题', 'aksrt'), + 'id' => 'post_title', + 'std' => (string) get_the_title($post_id), + 'type' => 'text', + ]; - if ($is_single) { - $html .= $this->text_field('subtitle', __('副标题', 'aksrt'), (string) ($meta['subtitle'] ?? '')); - $layout = (string) ($meta['show_layout'] ?? 'false'); - $html .= ''; - $html .= $this->number_field('like', __('点赞数', 'aksrt'), $like); - $html .= $this->number_field('views', __('阅读数', 'aksrt'), $views); - $html .= $this->checkbox_field('no_article-navs', __('不显示目录树', 'aksrt'), !empty($meta['no_article-navs'])); - $html .= $this->checkbox_field('article_maxheight_xz', __('限制文章最大高度', 'aksrt'), !empty($meta['article_maxheight_xz'])); + if (is_single()) { + $inputs[] = [ + 'name' => __('副标题', 'aksrt'), + 'id' => 'subtitle', + 'std' => (string) ($meta['subtitle'] ?? ''), + 'type' => 'text', + ]; + $inputs[] = [ + 'name' => __('文章格式', 'aksrt'), + 'id' => 'post_format', + 'std' => (string) get_post_format($post_id), + 'type' => 'select', + 'options' => $this->post_format_options(), + ]; + $inputs[] = [ + 'name' => __('点赞数', 'aksrt'), + 'id' => 'like', + 'std' => (int) ($meta['like'] ?? 0), + 'type' => 'number', + ]; + $inputs[] = [ + 'name' => __('阅读数', 'aksrt'), + 'id' => 'views', + 'std' => (int) ($meta['views'] ?? 0), + 'type' => 'number', + ]; + $inputs[] = [ + 'name' => __('目录树', 'aksrt'), + 'id' => 'no_article-navs', + 'std' => !empty($meta['no_article-navs']), + 'type' => 'checkbox', + 'desc' => __('不显示', 'aksrt'), + ]; + $inputs[] = [ + 'name' => __('文章高度', 'aksrt'), + 'id' => 'article_maxheight_xz', + 'std' => !empty($meta['article_maxheight_xz']), + 'type' => 'checkbox', + 'desc' => __('限制文章最大高度', 'aksrt'), + ]; } - $html .= ''; + $inputs[] = [ + 'name' => __('评论', 'aksrt'), + 'id' => 'comments_open', + 'std' => comments_open($post_id) ? 'open' : '', + 'type' => 'radio', + 'options' => [ + 'open' => __('开启', 'aksrt'), + '' => __('关闭', 'aksrt'), + ], + ]; - $html .= $this->hidden_fields('post', $post_id, $post_type); - $html .= '
' - . '
'; - $html .= '
'; + if (is_page()) { + $inputs[] = [ + 'name' => __('标题样式', 'aksrt'), + 'id' => 'page_header_style', + 'std' => (string) get_post_meta($post_id, 'page_header_style', true), + 'type' => 'radio', + 'options' => [ + '' => __('跟随主题', 'aksrt'), + 'not' => __('不显示', 'aksrt'), + '1' => __('简单样式', 'aksrt'), + '2' => __('卡片样式', 'aksrt'), + '3' => __('封面图样式', 'aksrt'), + ], + ]; + $inputs[] = [ + 'name' => __('模块布局', 'aksrt'), + 'id' => 'widgets_register', + 'type' => 'radio', + 'options' => [ + '1' => __('开启', 'aksrt'), + '0' => __('关闭', 'aksrt'), + ], + 'std' => (string) get_post_meta($post_id, 'widgets_register', true), + ]; + $inputs[] = [ + 'id' => 'widgets_register_container', + 'type' => 'checkbox', + 'class' => 'compact', + 'std' => (array) get_post_meta($post_id, 'widgets_register_container', true), + 'name' => __('创建容器位置', 'aksrt'), + 'desc' => __('请根据需要合理开启,如果用不到则不要开启', 'aksrt') . '
' . __('保存页面后即可进入小工具或模块配置添加模块', 'aksrt') . '
' . __('注意:开启此功能的页面不能太多,太多会影响性能,建议控制在10个以内', 'aksrt') . '
', + 'options' => [ + 'sidebar' => __('侧边栏', 'aksrt'), + 'top_fluid' => __('顶部全宽度', 'aksrt'), + 'top_content' => __('主内容上面', 'aksrt'), + 'bottom_content' => __('主内容下面', 'aksrt'), + 'bottom_fluid' => __('底部全宽度', 'aksrt'), + ], + ]; + } - return $this->wrap_form($html); + return $this->render_inputs($inputs); } - private function term_modal_html(int $term_id, string $taxonomy): string { + /** AKSRT 注册的文章格式(standard + add_theme_support 注册项)。 */ + private function post_format_options(): array { + $options = ['standard' => __('标准', 'aksrt')]; + foreach (get_theme_support('post-formats') ? (array) get_theme_support('post-formats')[0] : [] as $format) { + $options[$format] = esc_html(get_post_format_string($format)); + } + return $options; + } + + /** 分类术语的弹窗字段(对齐参考主题 tax 分支)。 */ + private function term_inputs(int $term_id, string $taxonomy): string { $term = get_term($term_id, $taxonomy); if (!$term || is_wp_error($term)) { - return '

' . esc_html__('未找到可用数据', 'aksrt') . '

'; + return '

' . esc_html__('未找到可用term数据', 'aksrt') . '

'; } - $html = '
'; - $html .= '

' . esc_html($term->name) . '

'; - $html .= $this->text_field('term_name', __('名称', 'aksrt'), (string) $term->name); - $html .= $this->text_field('term_description', __('描述', 'aksrt'), (string) $term->description); - $html .= $this->hidden_fields('tax', $term_id, $taxonomy); - $html .= '
' - . '
'; - $html .= '
'; - - return $this->wrap_form($html); + return $this->render_inputs([ + [ + 'name' => __('名称', 'aksrt'), + 'id' => 'term_name', + 'std' => (string) $term->name, + 'type' => 'text', + ], + [ + 'name' => __('描述', 'aksrt'), + 'id' => 'term_description', + 'std' => (string) $term->description, + 'type' => 'text', + ], + ]); } - private function wrap_form(string $inner): string { - return '
' - . '' - . wp_nonce_field('aksrt_frontend_set', '_wpnonce', true, false) - . $inner - . '
'; + /** 表单控件构建(对齐参考主题 zib_edit_input_construct)。 */ + private function render_inputs(array $inputs): string { + $output = ''; + foreach ($inputs as $meta) { + $type = (string) ($meta['type'] ?? ''); + $value = $meta['std'] ?? ''; + $desc = (string) ($meta['desc'] ?? ''); + $class = $type !== '' ? ' option-' . $type : ''; + + if (isset($meta['class'])) { + $class .= ' ' . $meta['class']; + } + + $control = ''; + if ($type === 'text') { + $control = ''; + } elseif ($type === 'number') { + $control = ''; + } elseif ($type === 'select') { + $control = '
'; + } elseif ($type === 'radio') { + foreach ((array) ($meta['options'] ?? []) as $key => $option) { + $control .= ''; + } + } elseif ($type === 'checkbox') { + $options = (array) ($meta['options'] ?? []); + if ($options) { + foreach ($options as $key => $option) { + $checked = is_array($value) ? (in_array($key, $value, true) ? ' checked' : '') : ((string) $value === (string) $key ? ' checked' : ''); + $control .= ''; + } + } else { + $control = ''; + } + } + + $output .= '
' . "\n"; + $output .= '
' . esc_html((string) ($meta['name'] ?? '')) . '
' . "\n"; + $output .= '
' . "\n"; + $output .= $control; + if ($desc !== '' && $type !== 'checkbox') { + $output .= '
' . $this->rich_desc($desc) . '
' . "\n"; + } + $output .= '
' . "\n"; + $output .= '
' . "\n"; + } + + return $output; } - private function hidden_fields(string $type, int $id, string $subtype): string { - return '' - . '' - . ''; - } - - private function text_field(string $name, string $label, string $value): string { - return ''; - } - - private function number_field(string $name, string $label, int $value): string { - return ''; - } - - private function checkbox_field(string $name, string $label, bool $checked): string { - return '
'; + /** + * 描述文案:允许
、强调标签与来源可信的主题文案(对齐参考主题的富文本描述)。 + * + * @param string $desc 内部生成的描述文案。 + */ + private function rich_desc(string $desc): string { + return wp_kses($desc, [ + 'br' => [], + 'div' => ['class' => []], + 'span' => ['class' => []], + 'em' => [], + 'strong' => [], + 'b' => [], + 'i' => [], + ]); } } diff --git a/tests/widget-system/frontend-page-edit-harness.php b/tests/widget-system/frontend-page-edit-harness.php new file mode 100644 index 00000000..c8dd5116 --- /dev/null +++ b/tests/widget-system/frontend-page-edit-harness.php @@ -0,0 +1,260 @@ + (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 = ''; + 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"; +} diff --git a/tests/widget-system/system-tools-contract.test.cjs b/tests/widget-system/system-tools-contract.test.cjs index 649579e7..a5053aab 100644 --- a/tests/widget-system/system-tools-contract.test.cjs +++ b/tests/widget-system/system-tools-contract.test.cjs @@ -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, /'
' \. "\\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/); +});