Files
wordpress-theme-aksrt/src/Admin/PageConfigMetaBox.php
T
kate522 cd7082454a fix(widgets): preserve badge HTML in icon card title/desc like reference theme
wp_kses_post() strips the custom <badge> element, so icon card title/desc
lost admin-authored markup (e.g. <badge class="ml6 jb-blue">GO</badge>)
both at save time and render time, diverging from the reference theme
which stores and echoes these fields raw.

- add shared inline whitelist (aksrt_icon_card_kses_tags/aksrt_icon_card_kses)
  and route both card renderers plus the media-brand fallback through it
- drop the dead unused $allow array in aksrt_icon_cover_card
- add 'rich_html' sanitize type (FieldSchema + SchemaProvider) and mark
  icon card title/desc fields in widget schemas, page display schema and
  the page config meta box save path
- align header slider card module with reference show_widget_bg branches
  (aksrt-panel/flex1 vs padding-10 wrapper/relative-h)
- update widget contract tests to pin the whitelist behavior
2026-09-19 16:39:59 +08:00

310 lines
15 KiB
PHP

<?php
/**
* Per-page legacy header slider configuration.
*
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
final class PageConfigMetaBox {
private const META_KEY = 'page_config';
private const NONCE_ACTION = 'aksrt_page_config_save';
private const NONCE_NAME = 'aksrt_page_config_nonce';
public static function init(): void {
add_action('add_meta_boxes_page', [self::class, 'register']);
add_action('save_post_page', [self::class, 'save']);
}
public static function register(): void {
add_meta_box(
'aksrt-page-config',
__('页面配置', 'aksrt'),
[self::class, 'render'],
'page',
'normal',
'high'
);
}
/** @param \WP_Post $post */
public static function render($post): void {
$config = get_post_meta((int) $post->ID, self::META_KEY, true);
$config = is_array($config) ? $config : [];
wp_nonce_field(self::NONCE_ACTION, self::NONCE_NAME);
$show_type = (string) ($config['header_slider_show_type'] ?? '');
$devices = [
'header_slider_search_s' => (array) ($config['header_slider_search_s'] ?? []),
'header_slider_card_s' => (array) ($config['header_slider_card_s'] ?? []),
];
?>
<style>
.aksrt-page-config-grid{display:grid;grid-template-columns:220px minmax(0,1fr);gap:14px 20px;align-items:start}
.aksrt-page-config-grid>label{font-weight:600;padding-top:7px}.aksrt-page-config-grid textarea{width:100%;font-family:Consolas,monospace}
.aksrt-page-config-grid .description{margin:4px 0 0}.aksrt-page-config-wide{grid-column:1/-1}
@media(max-width:782px){.aksrt-page-config-grid{grid-template-columns:1fr}.aksrt-page-config-wide{grid-column:auto}}
</style>
<div class="aksrt-page-config-grid">
<label for="aksrt-header-slider-show"><?php esc_html_e('独立配置顶部多功能组件', 'aksrt'); ?></label>
<div>
<label><input id="aksrt-header-slider-show" type="checkbox" name="page_config[header_slider_show]" value="1" <?php checked(!empty($config['header_slider_show'])); ?>> <?php esc_html_e('为当前页面使用独立内容和显示规则', 'aksrt'); ?></label>
<p class="description"><?php esc_html_e('仅当主题设置的“顶部多功能组件”已勾选当前页面所属类型时生效;此处不能单独启用组件。', 'aksrt'); ?></p>
</div>
<label><?php esc_html_e('显示规则', 'aksrt'); ?></label>
<div>
<?php foreach (['' => __('全部显示', 'aksrt'), 'only_pc' => __('仅在 PC 端显示', 'aksrt'), 'only_sm' => __('仅在移动端显示', 'aksrt')] as $value => $label) : ?>
<label style="margin-right:16px"><input type="radio" name="page_config[header_slider_show_type]" value="<?php echo esc_attr($value); ?>" <?php checked($show_type, $value); ?>> <?php echo esc_html($label); ?></label>
<?php endforeach; ?>
</div>
<label for="aksrt-header-slider-nav-color"><?php esc_html_e('导航栏文字颜色', 'aksrt'); ?></label>
<div><input id="aksrt-header-slider-nav-color" type="color" name="page_config[header_slider_nav_color]" value="<?php echo esc_attr((string) ($config['header_slider_nav_color'] ?? '#ffffff')); ?>"></div>
<?php self::render_json_field('header_slider', __('背景内容', 'aksrt'), $config['header_slider'] ?? self::slide_defaults(), 16); ?>
<?php self::render_json_field('header_slider_option', __('背景显示设置', 'aksrt'), $config['header_slider_option'] ?? self::slider_defaults(), 12); ?>
<label><?php esc_html_e('叠加搜索组件', 'aksrt'); ?></label>
<div><?php self::render_devices('header_slider_search_s', $devices['header_slider_search_s']); ?></div>
<?php self::render_json_field('header_slider_search_option', __('搜索组件设置', 'aksrt'), $config['header_slider_search_option'] ?? self::search_defaults(), 12); ?>
<label><?php esc_html_e('叠加封面卡片组件', 'aksrt'); ?></label>
<div><?php self::render_devices('header_slider_card_s', $devices['header_slider_card_s']); ?></div>
<?php self::render_json_field('header_slider_card_option', __('图标卡片设置', 'aksrt'), $config['header_slider_card_option'] ?? self::card_defaults(), 16); ?>
</div>
<p class="description"><?php esc_html_e('复杂字段使用参考主题原始数组结构的 JSON;保存后统一写入 page_config 序列化元数据,不读取任何旧字段。', 'aksrt'); ?></p>
<?php
}
/** @param mixed $value */
private static function render_json_field(string $key, string $label, $value, int $rows): void {
$json = wp_json_encode(is_array($value) ? $value : [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
?>
<label for="aksrt-<?php echo esc_attr($key); ?>"><?php echo esc_html($label); ?></label>
<div><textarea id="aksrt-<?php echo esc_attr($key); ?>" name="page_config[<?php echo esc_attr($key); ?>]" rows="<?php echo (int) $rows; ?>"><?php echo esc_textarea((string) $json); ?></textarea></div>
<?php
}
/** @param list<string> $selected */
private static function render_devices(string $key, array $selected): void {
foreach (['pc_s' => __('PC 端显示', 'aksrt'), 'm_s' => __('移动端显示', 'aksrt')] as $value => $label) {
printf(
'<label style="margin-right:16px"><input type="checkbox" name="page_config[%1$s][]" value="%2$s" %3$s> %4$s</label>',
esc_attr($key),
esc_attr($value),
checked(in_array($value, $selected, true), true, false),
esc_html($label)
);
}
}
public static function save(int $post_id): void {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_page', $post_id)) {
return;
}
if (!isset($_POST[self::NONCE_NAME]) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST[self::NONCE_NAME])), self::NONCE_ACTION)) {
return;
}
if (!isset($_POST['page_config']) || !is_array($_POST['page_config'])) {
delete_post_meta($post_id, self::META_KEY);
return;
}
$raw = wp_unslash($_POST['page_config']);
$config = [
'header_slider_show' => !empty($raw['header_slider_show']),
'header_slider_show_type' => self::enum((string) ($raw['header_slider_show_type'] ?? ''), ['', 'only_pc', 'only_sm']),
'header_slider_nav_color' => sanitize_hex_color((string) ($raw['header_slider_nav_color'] ?? '')) ?: '#ffffff',
'header_slider' => self::sanitize_slides(self::decode_json($raw['header_slider'] ?? '')),
'header_slider_option' => self::sanitize_slider_options(self::decode_json($raw['header_slider_option'] ?? '')),
'header_slider_search_s' => self::sanitize_devices($raw['header_slider_search_s'] ?? []),
'header_slider_search_option' => self::sanitize_search_options(self::decode_json($raw['header_slider_search_option'] ?? '')),
'header_slider_card_s' => self::sanitize_devices($raw['header_slider_card_s'] ?? []),
'header_slider_card_option' => self::sanitize_card_options(self::decode_json($raw['header_slider_card_option'] ?? '')),
];
update_post_meta($post_id, self::META_KEY, $config);
}
/** @param mixed $value @return array<mixed> */
private static function decode_json($value): array {
if (is_array($value)) {
return $value;
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : [];
}
/** @param mixed $value @return list<string> */
private static function sanitize_devices($value): array {
return array_values(array_intersect(array_map('sanitize_key', (array) $value), ['pc_s', 'm_s']));
}
/** @param list<string> $allowed */
private static function enum(string $value, array $allowed): string {
return in_array($value, $allowed, true) ? $value : $allowed[0];
}
/** @param array<mixed> $slides @return list<array<string,mixed>> */
private static function sanitize_slides(array $slides): array {
$clean = [];
foreach ($slides as $slide) {
if (!is_array($slide)) {
continue;
}
$layers = [];
foreach ((array) ($slide['image_layer'] ?? []) as $layer) {
if (!is_array($layer)) {
continue;
}
$layers[] = [
'image' => esc_url_raw((string) ($layer['image'] ?? '')),
'free_size' => !empty($layer['free_size']),
'align' => self::enum(sanitize_key((string) ($layer['align'] ?? 'center')), ['left', 'center', 'right']),
'parallax' => max(-200, min(200, (int) ($layer['parallax'] ?? 0))),
'parallax_scale' => max(1, min(200, (int) ($layer['parallax_scale'] ?? 100))),
'parallax_opacity' => max(1, min(100, (int) ($layer['parallax_opacity'] ?? 100))),
];
}
$text = is_array($slide['text'] ?? null) ? $slide['text'] : [];
$link = is_array($slide['link'] ?? null) ? $slide['link'] : [];
$clean[] = [
'background' => esc_url_raw((string) ($slide['background'] ?? '')),
'background_video' => esc_url_raw((string) ($slide['background_video'] ?? '')),
'video' => esc_url_raw((string) ($slide['video'] ?? '')),
'hide' => self::enum(sanitize_key((string) ($slide['hide'] ?? '')), ['', 'pc', 'm']),
'link' => [
'url' => esc_url_raw((string) ($link['url'] ?? '')),
'target' => !empty($link['target']) ? '_blank' : '',
'text' => sanitize_text_field((string) ($link['text'] ?? '')),
],
'image_layer' => $layers,
'text' => [
'title' => wp_kses_post((string) ($text['title'] ?? '')),
'desc' => wp_kses_post((string) ($text['desc'] ?? '')),
'text_align' => sanitize_html_class((string) ($text['text_align'] ?? 'left-bottom')),
'text_size_pc' => max(12, min(50, (int) ($text['text_size_pc'] ?? 30))),
'text_size_m' => max(12, min(50, (int) ($text['text_size_m'] ?? 20))),
'parallax' => max(-200, min(200, (int) ($text['parallax'] ?? 40))),
],
];
}
return $clean;
}
/** @param array<mixed> $value @return array<string,mixed> */
private static function sanitize_slider_options(array $value): array {
$defaults = array_merge(self::slider_defaults(), ['scale' => 40, 'max_height' => 500, 'min_height' => 180]);
$value = wp_parse_args($value, $defaults);
return [
'direction' => self::enum(sanitize_key((string) $value['direction']), ['horizontal', 'vertical']),
'loop' => !empty($value['loop']),
'button' => !empty($value['button']),
'pagination' => !empty($value['pagination']),
'effect' => self::enum(sanitize_key((string) $value['effect']), ['slide', 'fade', 'cube', 'coverflow', 'flip']),
'scale_height' => !empty($value['scale_height']),
'scale' => max(10, min(300, (int) $value['scale'])),
'auto_height' => !empty($value['auto_height']),
'max_height' => max(120, min(800, (int) $value['max_height'])),
'min_height' => max(100, min(500, (int) $value['min_height'])),
'pc_height' => max(120, min(800, (int) $value['pc_height'])),
'm_height' => max(100, min(500, (int) $value['m_height'])),
'spacebetween' => max(0, min(500, (int) $value['spacebetween'])),
'speed' => max(0, min(3000, (int) $value['speed'])),
'autoplay' => !empty($value['autoplay']),
'interval' => max(0, min(20, (int) $value['interval'])),
];
}
/** @param array<mixed> $value @return array<string,mixed> */
private static function sanitize_search_options(array $value): array {
$value = wp_parse_args($value, self::search_defaults());
return [
'show_keywords' => !empty($value['show_keywords']),
'popular_limit' => max(2, min(50, (int) $value['popular_limit'])),
'show_input_cat' => !empty($value['show_input_cat']),
'in_cat' => absint($value['in_cat']),
'more_cats' => array_values(array_filter(array_map('absint', (array) $value['more_cats']))),
'show_type' => !empty($value['show_type']),
'in_type' => sanitize_key((string) $value['in_type']),
'before_html' => wp_kses_post((string) $value['before_html']),
'after_html' => wp_kses_post((string) $value['after_html']),
];
}
/** @param array<mixed> $value @return array<string,mixed> */
private static function sanitize_card_options(array $value): array {
$value = wp_parse_args($value, self::card_defaults());
$cards = [];
foreach ((array) $value['cards'] as $card) {
if (!is_array($card)) {
continue;
}
$link = is_array($card['link'] ?? null) ? $card['link'] : [];
$cards[] = [
// legacy echoes card title/desc raw (admin authors <badge> etc.);
// wp_kses_post() would strip the custom badge element.
'title' => aksrt_icon_card_kses((string) ($card['title'] ?? '')),
'desc' => aksrt_icon_card_kses((string) ($card['desc'] ?? '')),
// Icon picker values may carry an inline "customsvg-<svg>" payload;
// sanitize_text_field would strip it down to a bare prefix.
'icon' => function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) ($card['icon'] ?? ''))
: sanitize_text_field((string) ($card['icon'] ?? '')),
'customize_icon' => wp_kses_post((string) ($card['customize_icon'] ?? '')),
'icon_class' => sanitize_text_field((string) ($card['icon_class'] ?? '')),
'link' => [
'url' => esc_url_raw((string) ($link['url'] ?? '')),
'target' => !empty($link['target']) ? '_blank' : '',
'text' => sanitize_text_field((string) ($link['text'] ?? '')),
],
];
}
return ['icon_radius4' => !empty($value['icon_radius4']), 'cards' => $cards];
}
/** @return array<string,mixed> */
private static function slider_defaults(): array {
return ['direction' => 'horizontal', 'loop' => true, 'button' => true, 'pagination' => true, 'effect' => 'slide', 'scale_height' => false, 'auto_height' => false, 'pc_height' => 550, 'm_height' => 280, 'spacebetween' => 0, 'speed' => 0, 'autoplay' => true, 'interval' => 6];
}
/** @return list<array<string,mixed>> */
private static function slide_defaults(): array {
$base = defined('AKSRT_URI') ? trailingslashit(AKSRT_URI) . 'assets/img/' : '';
$slides = [];
foreach (['slider-bg.jpg', 'user_t.jpg'] as $image) {
$slides[] = [
'background' => $base . $image,
'link' => ['url' => home_url('/'), 'target' => '_blank'],
'text' => ['desc' => '', 'title' => ''],
'text_align' => 'left-bottom', 'text_parallax' => 30,
'text_size_m' => 20, 'text_size_pc' => 30,
];
}
return $slides;
}
/** @return array<string,mixed> */
private static function search_defaults(): array {
return ['show_keywords' => true, 'popular_limit' => 6, 'show_input_cat' => false, 'in_cat' => 0, 'more_cats' => [], 'show_type' => false, 'in_type' => 'post', 'before_html' => '<div class="em16 font-bold mb10">更优雅的Wordpress主题</div><div>这是导航栏幻灯片多功能组件,可在后台进行配置</div>', 'after_html' => ''];
}
/** @return array<string,mixed> */
private static function card_defaults(): array {
return ['icon_radius4' => false, 'cards' => [
['icon' => 'fa fas fa-gem', 'customize_icon' => '', 'icon_class' => 'c-yellow', 'link' => [], 'title' => '图标卡片', 'desc' => '这是一个图标卡片示例'],
['icon' => 'fa fas fa-star', 'customize_icon' => '', 'icon_class' => 'c-blue-2', 'link' => [], 'title' => '原创作品', 'desc' => '这是一个图标卡片示例'],
['icon' => 'fa fas fa-fire', 'customize_icon' => '', 'icon_class' => 'jb-pink', 'link' => [], 'title' => '灵感来源<badge class="ml6 jb-yellow">NEW</badge>', 'desc' => '这是一个图标卡片示例'],
['icon' => 'fa fas fa-wrench', 'customize_icon' => '', 'icon_class' => 'jb-cyan', 'link' => [], 'title' => '系统工具 <badge class="ml6 jb-blue">GO <span class="fa fas fa-angle-right em12"></span></badge>', 'desc' => '这是一个图标卡片示例'],
]];
}
}