fix(widgets): align slider media combo with the Zibll reference

- 渲染器:幻灯片按参考补回 span/a 包裹、lazyload swiper-lazy 懒加载契约、
  视频背景与图层视差属性条件;组合模块补回 is_show 门控、超管缺省提示、
  widget-graphic-cover mb 类与内层样式容器、卡片列入场动画类与 lazy_cover 开关
- Schema:幻灯片/图层/卡片字段补齐 library、unit、参考描述文案与分组按钮标题
- 后台表单:分组按钮文案(添加幻灯片/添加卡片/添加图层)、行编号格式、
  视频上传不渲染图片预览(FormRenderer + admin.js)
- 契约测试:新增 slider media combo 前后台参考契约断言

验证:php -l 全部通过;run.php 133 断言;node --test 243 项(仅主干既有
toast-contract 失败);validate-widget-golden 通过
This commit is contained in:
2026-09-05 14:17:03 +08:00
parent cf264d5e81
commit 65a69b1dec
5 changed files with 181 additions and 74 deletions
+132 -54
View File
@@ -10,101 +10,179 @@ defined('ABSPATH') || exit;
final class SliderRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
unset($context);
return $slug === 'slider_media_combo' ? self::combo($instance) : self::slider((array) ($instance['slides'] ?? []), (array) ($instance['option'] ?? []), true);
if ($slug === 'slider_media_combo') {
return self::combo($instance);
}
$slides = (array) ($instance['slides'] ?? []);
if (!$slides) {
// 参考主题幻灯片(新):无可显示内容时仅向超级管理员提示。
if (function_exists('is_super_admin') && is_super_admin()) {
return '<div class="c-red muted-box mb20"><b>[' . esc_html__('幻灯片(新)', 'aksrt') . ']' . esc_html__('幻灯片模块:', 'aksrt') . '</b>' . esc_html__('当前配置下没有可显示的内容,请正确配置或移出该模块', 'aksrt') . '</div>';
}
return '';
}
return self::slider($slides, (array) ($instance['option'] ?? []), true);
}
private static function slider(array $slides, array $option, bool $with_margin = false): string {
$option = array_merge(['direction' => 'horizontal', 'loop' => true, 'button' => true, 'pagination' => true, 'effect' => 'slide', 'scale_height' => true, 'scale' => 35, 'auto_height' => false, 'max_height' => 500, 'min_height' => 180, 'pc_height' => 400, 'm_height' => 200, 'spacebetween' => 15, 'speed' => 0, 'autoplay' => true, 'interval' => 4], $option);
$is_mobile = function_exists('wp_is_mobile') && wp_is_mobile();
$lazy = aksrt_is_lazy('lazy_sider');
$lazy_src = aksrt_get_lazy_thumb('lg');
$items = ''; $count = 0;
foreach ($slides as $slide) {
if (!is_array($slide)) continue;
$hide = (string) ($slide['hide'] ?? '');
if (($is_mobile && $hide === 'm') || (!$is_mobile && $hide === 'pc')) continue;
$background = esc_url((string) ($slide['background'] ?? '')); $video = esc_url((string) ($slide['background_video'] ?? ''));
if ($background === '' && ($video === '' || $is_mobile)) continue;
$alt = (string) (($slide['text']['title'] ?? '') ?: __('幻灯片', 'aksrt'));
$slide_class = '';
if ($video !== '' && !$is_mobile) { $slide_class = ' slide-background-video'; $media = '<video autoplay loop muted playsinline class="fit-cover radius8" src="' . $video . '"' . ($background !== '' ? ' poster="' . $background . '"' : '') . '></video><div class="absolute"></div>'; }
else $media = '<img class="radius8 swiper-lazy" src="' . esc_url(defined('AKSRT_URI') ? AKSRT_URI . '/assets/img/thumbnail-lg.svg' : $background) . '" data-src="' . $background . '" alt="' . esc_attr($alt) . '">';
$slide_class = (string) ($slide['class'] ?? '');
$background = (string) ($slide['background'] ?? '');
$video = (string) ($slide['background_video'] ?? '');
$media = '';
if ($video !== '' && !$is_mobile) {
$slide_class .= ' slide-background-video';
$poster = $background !== '' ? ' poster="' . esc_url($background) . '"' : '';
$media = '<video autoplay="" loop="" muted="" class="fit-cover radius8' . ($lazy ? ' lazyload' : '') . '"' . ($lazy ? ' data-src="' . esc_url($video) . '" src=""' : ' src="' . esc_url($video) . '"') . $poster . '></video><div class="absolute"></div>';
}
if ($media === '' && $background !== '') {
$alt = (string) (($slide['text']['title'] ?? '') ?: ($slide['link']['text'] ?? '') ?: __('幻灯片', 'aksrt'));
$media = '<img class="radius8' . ($lazy ? ' lazyload swiper-lazy' : '') . '" ' . ($lazy ? 'data-src="' . esc_url($background) . '" src="' . esc_url($lazy_src) . '"' : 'src="' . esc_url($background) . '"') . ' alt="' . esc_attr($alt) . '">';
}
if ($media === '') continue;
$layers = '';
foreach ((array) ($slide['image_layer'] ?? []) as $layer) {
if (!is_array($layer) || empty($layer['image'])) continue;
$attrs = ''; $parallax = (int) ($layer['parallax'] ?? 0); $scale = (float) ($layer['parallax_scale'] ?? 100) / 100; $opacity = (float) ($layer['parallax_opacity'] ?? 100) / 100;
if ($parallax) $attrs .= ' data-swiper-parallax="' . $parallax . '%"'; if ($scale !== 1.0) $attrs .= ' data-swiper-parallax-scale="' . esc_attr((string) $scale) . '"'; if ($opacity !== 1.0) $attrs .= ' data-swiper-parallax-opacity="' . esc_attr((string) $opacity) . '"';
$parallax = (int) ($layer['parallax'] ?? 0);
$parallax_attr = $parallax ? ' data-swiper-parallax="' . $parallax . '%"' : '';
$opacity = (float) ($layer['parallax_opacity'] ?? 100) / 100;
$parallax_attr .= ($parallax && $opacity && $opacity !== 1.0) ? ' data-swiper-parallax-opacity="' . esc_attr((string) $opacity) . '"' : '';
$scale = (float) ($layer['parallax_scale'] ?? 100) / 100;
$parallax_attr .= ($scale && $scale !== 1.0) ? ' data-swiper-parallax-scale="' . esc_attr((string) $scale) . '"' : '';
$class = !empty($layer['free_size']) ? ' slide-layer text-' . esc_attr((string) ($layer['align'] ?? 'center')) : '';
$layers .= '<div class="absolute' . $class . '"' . $attrs . '><img class="radius8 swiper-lazy" src="' . esc_url(defined('AKSRT_URI') ? AKSRT_URI . '/assets/img/thumbnail-lg.svg' : (string) $layer['image']) . '" data-src="' . esc_url((string) $layer['image']) . '" alt="' . esc_attr($alt) . '"></div>';
$layers .= '<div class="absolute' . $class . '"' . $parallax_attr . '><img class="radius8' . ($lazy ? ' lazyload swiper-lazy' : '') . '" ' . ($lazy ? 'data-src="' . esc_url((string) $layer['image']) . '" src="' . esc_url($lazy_src) . '"' : 'src="' . esc_url((string) $layer['image']) . '"') . ' alt="' . esc_attr((string) (($slide['text']['title'] ?? '') ?: __('幻灯片', 'aksrt'))) . '"></div>';
}
$text = (array) ($slide['text'] ?? []); $copy = '';
if (!empty($text['title'])) { $style = ''; if (!empty($text['text_size_pc'])) $style .= '--text-size-pc:' . (int) $text['text_size_pc'] . 'px;'; if (!empty($text['text_size_m'])) $style .= '--text-size-m:' . (int) $text['text_size_m'] . 'px;'; $copy = '<div class="abs-center slide-text ' . esc_attr((string) ($text['text_align'] ?? 'left-bottom')) . '"' . ($style ? ' style="' . esc_attr($style) . '"' : '') . '><div class="slide-title">' . wp_kses_post((string) $text['title']) . '</div>' . (!empty($text['desc']) ? '<div class="slide-desc">' . wp_kses_post((string) $text['desc']) . '</div>' : '') . '</div>'; if (!empty($text['parallax'])) $copy = '<div class="absolute" data-swiper-parallax="' . (int) $text['parallax'] . '">' . $copy . '</div>'; }
$body = $media . $layers . $copy; $link = RendererHelpers::link((array) ($slide['link'] ?? [])); if ($link['url'] !== '') $body = '<a href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '>' . $body . '</a>';
$items .= '<div class="swiper-slide' . esc_attr($slide_class) . '">' . $body . '</div>'; $count++;
$body = $media . $layers . $copy;
$link = RendererHelpers::link((array) ($slide['link'] ?? []));
$body = $link['url'] !== ''
? '<a' . ($link['target'] !== '' ? ' target="_blank"' : '') . ' href="' . $link['url'] . '">' . $body . '</a>'
: '<span>' . $body . '</span>';
$items .= '<div class="swiper-slide' . ($slide_class !== '' ? ' ' . esc_attr(trim($slide_class)) : '') . '">' . $body . '</div>'; $count++;
}
if (!$count) return RendererHelpers::empty_state(__('请配置幻灯片', 'aksrt'));
$class = 'new-swiper slide-widget' . ($with_margin ? ' mb20' : '') . ($option['scale_height'] && $option['direction'] !== 'vertical' ? ' scale-height' : '') . ($count === 1 ? ' swiper-container-initialized' : ''); $attrs = ' data-direction="' . esc_attr((string) $option['direction']) . '" data-effect="' . esc_attr((string) $option['effect']) . '"';
if (!empty($option['loop']) && $count > 1) $attrs .= ' data-loop="true"'; if (!empty($option['autoplay']) && $count > 1) $attrs .= ' data-autoplay="1" data-interval="' . ((int) $option['interval'] * 1000) . '"'; if ((int) $option['speed'] > 0) $attrs .= ' data-speed="' . (int) $option['speed'] . '"'; $attrs .= ' data-spaceBetween="' . (int) $option['spacebetween'] . '"';
if ($option['auto_height']) $attrs .= ' auto-height="true" style="--max-height:' . (int) $option['max_height'] . 'px;--min-height:' . (int) $option['min_height'] . 'px;"'; else $attrs .= ' style="' . esc_attr($option['scale_height'] && $option['direction'] !== 'vertical' ? '--scale-height:' . (int) $option['scale'] . '%;' : '--pc-height:' . (int) $option['pc_height'] . 'px;--m-height:' . (int) $option['m_height'] . 'px;') . '"';
$html = '<div class="' . esc_attr($class) . '"' . $attrs . '><div class="swiper-wrapper">' . $items . '</div>'; if (!empty($option['button']) && $count > 1) $html .= '<div class="swiper-button-prev"></div><div class="swiper-button-next"></div>'; if (!empty($option['pagination']) && $count > 1) $html .= '<div class="swiper-pagination kaoyou"></div>'; return '<div class="relative zib-slider">' . $html . '</div>';
if (!$count) return '';
$class = 'new-swiper slide-widget' . ($with_margin ? ' mb20' : '') . ($option['scale_height'] && $option['direction'] !== 'vertical' ? ' scale-height' : '') . ($count === 1 ? ' swiper-container-initialized' : '');
$attrs = ' data-direction="' . esc_attr((string) $option['direction']) . '" data-effect="' . esc_attr((string) $option['effect']) . '"';
if (!empty($option['loop'])) $attrs .= ' data-loop="true"';
$interval = (int) $option['interval'];
if ($interval < 999) $interval *= 1000;
if (!empty($option['autoplay'])) $attrs .= ' data-autoplay="1" data-interval="' . $interval . '"';
if ((int) $option['speed'] > 299) $attrs .= ' data-speed="' . (int) $option['speed'] . '"';
$attrs .= ' data-spaceBetween="' . (int) $option['spacebetween'] . '"';
if ($option['auto_height'] && $option['direction'] !== 'vertical') {
$attrs .= ' auto-height="true" style="--max-height:' . (int) $option['max_height'] . 'px;--min-height:' . (int) $option['min_height'] . 'px;"';
} elseif ($option['scale_height'] && $option['direction'] !== 'vertical') {
$attrs .= ' style="--scale-height:' . (int) $option['scale'] . '%;"';
} else {
$attrs .= ' style="--pc-height:' . (int) $option['pc_height'] . 'px;--m-height:' . (int) $option['m_height'] . 'px;"';
}
$html = '<div class="' . esc_attr($class) . '"' . $attrs . '><div class="swiper-wrapper">' . $items . '</div>';
if (!empty($option['button']) && $count > 1) $html .= '<div class="swiper-button-prev"></div><div class="swiper-button-next"></div>';
if (!empty($option['pagination']) && $count > 1) $html .= '<div class="swiper-pagination kaoyou"></div>';
return '<div class="relative zib-slider">' . $html . '</div>';
}
private static function combo(array $instance): string {
$option = (array) ($instance['option'] ?? []); $option['direction'] = 'horizontal'; $option['scale_height'] = true; $option['scale'] = (int) ($instance['slide_scale'] ?? 50);
$slide_list = (array) ($instance['slides'] ?? []);
$slider = !empty($slide_list[0]) ? self::slider($slide_list, $option) : '';
$has_slider = !empty($instance['slides'][0]);
$has_covers = !empty($instance['covers'][0]['image']) || !empty($instance['covers'][0]['video']);
// 参考主题 is_show 门控:幻灯片需含背景媒体才视为有内容。
$show_slider = $has_slider && (!empty($instance['slides'][0]['background']) || !empty($instance['slides'][0]['background_video']));
if (!$show_slider && !$has_covers) return '';
$slide_scale = max(0, (int) ($instance['slide_scale'] ?? 50));
$slider = '';
if ($has_slider && is_array($instance['option'])) {
$option = $instance['option'];
$option['class'] = 'slide-widget';
$option['direction'] = 'horizontal';
$option['scale_height'] = true;
$option['scale'] = $slide_scale;
$slider = self::slider((array) $instance['slides'], $option);
}
$m_layout_type = (string) ($instance['m_layout_type'] ?? '');
$layout_ratio = max(0, (int) ($instance['layout_ratio'] ?? 60));
$cover_row_count = max(1, (int) ($instance['pc_row'] ?? 2));
$cover_col_count = max(1, (int) ($instance['pc_col'] ?? 2));
$cover_gap = 3;
// 右侧卡片整体长宽比由左侧幻灯片宽度占比与长宽比推导,gap 与参考主题一致。
$cover_base_ratio = $layout_ratio < 100 ? (($layout_ratio * $slide_scale) / (100 - $layout_ratio)) : 0;
$cover_ratio = (($cover_base_ratio - ($cover_col_count - 1) * $cover_gap) / $cover_col_count) * $cover_row_count;
$cover_m_row = max(1, (int) ($instance['m_row'] ?? 2));
$m_cover_ratio = (int) ($instance['m_cover_ratio'] ?? 45);
if ($m_cover_ratio < 0) $m_cover_ratio = (int) $cover_ratio;
if ($m_layout_type === 'default') $cover_m_row = $cover_row_count;
$style_attr = ' --layout-ratio:' . $layout_ratio . '%; --cover-ratio:' . $cover_ratio . '%; --m-cover-ratio:' . $m_cover_ratio . '%;';
$is_mobile = function_exists('wp_is_mobile') && wp_is_mobile();
$cover_list = array_values((array) ($instance['covers'] ?? []));
$cover_list = (array) ($instance['covers'] ?? []);
$is_row = count($cover_list) > 1;
$layout_type = (string) ($instance['m_layout_type'] ?? 'vertical');
$pc_row = max(1, min(4, (int) ($instance['pc_row'] ?? 2)));
$m_row = $layout_type === 'default' ? $pc_row : max(1, min(6, (int) ($instance['m_row'] ?? 2)));
$row_class = 'col-sm-' . (int) (12 / $pc_row) . ($m_row > 1 ? ' col-xs-' . (int) (12 / $m_row) : '');
$m_cover_ratio = max(0, (int) ($instance['m_cover_ratio'] ?? 45));
$mask = max(0, min(90, (int) ($instance['mask_opacity'] ?? 0))) / 100;
$cover_style_vars = '--font-size:' . (int) ($instance['font_size_pc'] ?? 16) . 'px;--font-size-sm:' . (int) ($instance['font_size_m'] ?? 14) . 'px;';
if (!empty($instance['font_bold'])) $cover_style_vars .= '--font-weight:bold;--font-weight-sm:bold;';
$row_class = 'col-sm-' . (int) (12 / $cover_row_count);
$row_class .= $cover_m_row > 1 ? ' col-xs-' . (int) (12 / $cover_m_row) : '';
$animation_class = self::animation_class($instance);
$font_size_pc = (int) ($instance['font_size_pc'] ?? 16);
$font_size_m = (int) ($instance['font_size_m'] ?? 14);
$font_color = (string) ($instance['font_color'] ?? '');
if ($font_color !== '') $cover_style_vars .= '--color:' . $font_color . ';--color-sm:' . $font_color . ';';
$font_style = '';
$font_style .= $font_size_pc && 14 !== $font_size_pc ? '--font-size:' . $font_size_pc . 'px;' : '';
$font_style .= $font_size_m && 14 !== $font_size_m ? '--font-size-sm:' . $font_size_m . 'px;' : '';
$font_style .= !empty($instance['font_bold']) ? '--font-weight:bold;--font-weight-sm:bold;' : '';
$font_style .= $font_color !== '' ? '--color:' . $font_color . ';--color-sm:' . $font_color . ';' : '';
$font_style_attr = $font_style !== '' ? ' style="' . esc_attr($font_style) . '"' : '';
$covers = '';
foreach ($cover_list as $cover) {
if (!is_array($cover)) continue;
$hide = (string) ($cover['hide'] ?? '');
if (($is_mobile && $hide === 'm') || (!$is_mobile && $hide === 'pc')) continue;
$image = esc_url((string) ($cover['image'] ?? ''));
$video = esc_url((string) ($cover['video'] ?? ''));
$title = (string) ($cover['title'] ?? '');
$link = RendererHelpers::link((array) ($cover['link'] ?? []));
$card = aksrt_graphic_card([
'class' => 'noshadow mb10',
'img' => $image,
'video' => $video,
'img' => (string) ($cover['image'] ?? ''),
'video' => (string) ($cover['video'] ?? ''),
'alt' => wp_strip_all_tags($title),
'link' => ['url' => $link['url'], 'target' => $link['target']],
'lazy' => aksrt_is_lazy('lazy_cover'),
'more' => trim($title) !== '' ? '<div class="abs-center text-center graphic-text this-font">' . wp_kses_post($title) . '</div>' : '',
'height_scale' => 0,
'mask_opacity' => (int) ($instance['mask_opacity'] ?? 0),
]);
if ($card === '') continue;
$covers .= '<div class="' . ($is_row ? esc_attr($row_class) : '') . '">' . $card . '</div>';
$col_class = trim($animation_class . ($is_row ? ' ' . $row_class : ''));
$covers .= '<div class="' . esc_attr($col_class) . '">' . $card . '</div>';
}
if ($covers !== '') {
$covers = '<div class="widget-graphic-cover" style="' . esc_attr($cover_style_vars) . '">' . ($is_row ? '<div class="row gutters-5">' : '') . $covers . ($is_row ? '</div>' : '') . '</div>';
$cover_html = '<div class="widget-graphic-cover ' . ($is_row ? 'mb10' : 'mb20') . '"><div' . $font_style_attr . '>' . ($is_row ? '<div class="row gutters-5">' : '') . $covers . ($is_row ? '</div>' : '') . '</div></div>';
$class = 'flex widget-slider-graphic m-' . sanitize_html_class($m_layout_type);
$html = '<div class="' . esc_attr($class) . '" style="' . esc_attr($style_attr) . '" data-row="' . $cover_row_count . '" data-col="' . $cover_col_count . '">';
if ($slider !== '') {
$html .= '<div class="slider-col mb20' . $animation_class . '">' . $slider . '</div>';
} elseif (function_exists('is_super_admin') && is_super_admin()) {
$html .= '<div class="slider-col mb20"><div class="alert alert-warning">' . esc_html__('当前模块下没有可显示的幻灯片,请添加幻灯片或移除此模块', 'aksrt') . '</div></div>';
}
if ($slider === '' && $covers === '') return RendererHelpers::empty_state(__('请配置幻灯片或卡片', 'aksrt'));
$layout = max(0, min(100, (int) ($instance['layout_ratio'] ?? 60)));
$slide_scale = max(0, (int) ($instance['slide_scale'] ?? 50));
$rows = $pc_row;
$columns = max(1, (int) ($instance['pc_col'] ?? 2));
$base = $layout < 100 ? (($layout * $slide_scale) / (100 - $layout)) : 0;
$cover_ratio = $columns > 0 ? ((($base - (($columns - 1) * 3)) / $columns) * $rows) : 0;
$style = '--layout-ratio:' . $layout . '%;--cover-ratio:' . $cover_ratio . '%;--m-cover-ratio:' . $m_cover_ratio . '%;';
$animation = '';
if (!empty($instance['obs_animation'])) {
$animation = ' obs-animate ani-' . sanitize_html_class((string) $instance['obs_animation']) . (!empty($instance['animation_repeat']) ? ' obs-animate-repeat' : '');
}
$class = 'flex widget-slider-graphic m-' . sanitize_html_class($layout_type);
$html = '<div class="' . esc_attr($class) . '" style="' . esc_attr($style) . '" data-row="' . $rows . '" data-col="' . $columns . '">';
if ($slider !== '') $html .= '<div class="slider-col mb20' . esc_attr($animation) . '">' . $slider . '</div>';
if ($covers !== '') $html .= '<div class="cover-col mb10">' . $covers . '</div>';
$html .= '<div class="cover-col mb10">' . $cover_html . '</div>';
return $html . '</div>';
}
/** 参考主题 obs_animation 入场动画类(作用于 slider-col 与每个卡片列)。 */
private static function animation_class(array $instance): string {
$animation = (string) ($instance['obs_animation'] ?? '');
if ($animation === '') return '';
$class = ' obs-animate ani-' . sanitize_html_class($animation);
if (!empty($instance['animation_repeat'])) $class .= ' obs-animate-repeat';
return $class;
}
}