From bb9413cba60bf9789a6d1d399aec2f8f01cbde1a Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 26 Aug 2026 08:41:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E5=B0=8F=E5=B7=A5=E5=85=B7=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=BA=86=E4=B8=80=E4=BA=9B=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + assets/widgets/admin-core.js | 18 ++- assets/widgets/admin.js | 11 +- assets/widgets/frontend.css | 53 +++++++ assets/widgets/frontend.js | 17 ++- src/WidgetSystem/FieldSchema.php | 8 +- src/WidgetSystem/FormRenderer.php | 27 +++- src/WidgetSystem/FrontendRenderer.php | 28 +++- src/WidgetSystem/PostQuery.php | 85 ++++++++--- src/WidgetSystem/PostRenderer.php | 25 ++-- .../Renderers/ContentRenderer.php | 28 +++- src/WidgetSystem/Renderers/SliderRenderer.php | 120 ++++++++++------ src/WidgetSystem/WidgetCatalog.php | 6 +- src/WidgetSystem/WidgetSchemas.php | 136 ++++++++++++++---- .../zibll-modern-widgets-contract.test.cjs | 36 +++++ 15 files changed, 463 insertions(+), 136 deletions(-) create mode 100644 tests/widget-system/zibll-modern-widgets-contract.test.cjs diff --git a/.gitignore b/.gitignore index 62efbd9f..959bef15 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ !/tests/widget-system/auth-source-contract.test.cjs !/tests/widget-system/user-badges-contract.test.cjs !/tests/widget-system/user-features-contract.test.cjs +!/tests/widget-system/zibll-modern-widgets-contract.test.cjs !/tests/widget-system/notification-preferences-contract.test.cjs !/tests/widget-system/message-center-contract.test.cjs !/tests/widget-system/message-center-url-harness.php diff --git a/assets/widgets/admin-core.js b/assets/widgets/admin-core.js index fcb5b3b8..4023fac7 100644 --- a/assets/widgets/admin-core.js +++ b/assets/widgets/admin-core.js @@ -19,9 +19,21 @@ function dependencyVisible(rule, current) { rule = parseDependency(rule); if (rule.length < 3) return true; - var expected = String(rule[2]); - var actual = String(current == null ? '' : current); - return rule[1] === '==' ? actual === expected : actual !== expected; + if (Array.isArray(rule[0])) { + return rule.every(function (part) { return dependencyVisible(part, current && typeof current === 'object' ? current[part[0]] : ''); }); + } + var ids = String(rule[0]).split('|'); + var operators = String(rule[1]).split('|'); + var expected = String(rule[2]).split('|'); + var values = current && typeof current === 'object' ? current : Object.fromEntries([[ids[0], current]]); + return ids.every(function (id, index) { + var actual = String(values[id] == null ? '' : values[id]); + var wanted = String(expected[index] == null ? expected[0] : expected[index]); + var operator = operators[index] || operators[0] || '!='; + if (operator === '==') return actual === wanted; + if (operator === 'any') return wanted.split(',').indexOf(actual) !== -1; + return actual !== wanted; + }); } function replaceRepeaterIndexes(name, rowIndexes) { diff --git a/assets/widgets/admin.js b/assets/widgets/admin.js index ef764a5e..b97437c9 100644 --- a/assets/widgets/admin.js +++ b/assets/widgets/admin.js @@ -81,12 +81,15 @@ } function evaluateDependencies($form) { - $form.find('[data-dependency]').each(function () { + $form.find('[data-dependency]').each(function () { const rule = core.parseDependency(this.dataset.dependency || '[]'); const $row = $(this).closest('.aksrt-widget-repeater__row'); - const current = fieldValue($row.length ? $row : $form, String(rule[0])); + const scope = $row.length ? $row : $form; + const ids = Array.isArray(rule[0]) ? rule[0].map((part) => part[0]) : String(rule[0] || '').split('|'); + const current = {}; + ids.forEach((id) => { current[id] = fieldValue(scope, id); }); this.hidden = !core.dependencyVisible(rule, current); - }); + }); } $(document) @@ -116,7 +119,7 @@ }) .on('click', '.aksrt-widget-upload__choose', function () { const $wrap = $(this).closest('.aksrt-widget-upload'); - const frame = wp.media({ title: '选择媒体', multiple: false }); + const frame = wp.media({ title: $wrap.data('media-library') === 'video' ? '选择视频' : '选择图片', library: $wrap.data('media-library') ? { type: $wrap.data('media-library') } : undefined, multiple: false }); frame.on('select', () => { const attachment = frame.state().get('selection').first().toJSON(); $wrap.find('input[type="url"]').val(attachment.url).trigger('change'); diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 856e0725..f640dabe 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -150,4 +150,57 @@ .aksrt-widget :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color: Highlight !important; } } .aksrt-widget--animate { animation: aksrt-widget-fade .55s both; }.aksrt-widget--animate-slideup { animation-name: aksrt-widget-slideup; }.aksrt-widget--animate-slidedown { animation-name: aksrt-widget-slidedown; }.aksrt-widget--animate-slideright { animation-name: aksrt-widget-slideright; }.aksrt-widget--animate-slideleft { animation-name: aksrt-widget-slideleft; }.aksrt-widget--animate-zoomin { animation-name: aksrt-widget-zoomin; }.aksrt-widget--animate-zoomout { animation-name: aksrt-widget-zoomout; }.aksrt-widget--animate-repeat { animation-iteration-count: infinite; animation-duration: 2s; } + +/* Zibll modern widget contracts */ +.widget-tab-post .tab-content > .tab-pane { display: none; } +.widget-tab-post .tab-content > .tab-pane.active { display: block; } +.widget-tab-post .list-inline { margin: 0 0 15px; padding: 0; list-style: none; } +.widget-tab-post .list-inline > li { display: inline-block; margin: 0 15px 0 0; } +.widget-tab-post .list-inline > li > a { color: inherit; text-decoration: none; cursor: pointer; } +.widget-tab-post .list-inline > li.active > a { color: var(--focus-color, #f04494); } +.widget-tab-post .tab-nav-theme > li { position: relative; padding-bottom: 5px; } +.widget-tab-post .tab-nav-theme > li.active::before { content: ''; position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--focus-color, #f04494); } +.widget-tab-post .post_ajax_loader { min-height: 40px; } +.widget-slider-graphic { gap: 10px; align-items: stretch; } +.widget-slider-graphic .slider-col { width: var(--layout-ratio, 60%); min-width: 0; } +.widget-slider-graphic .cover-col { width: calc(100% - var(--layout-ratio, 60%)); } +.widget-slider-graphic .widget-graphic-cover { height: 100%; } +.widget-slider-graphic .widget-graphic-cover > .row { height: 100%; margin: 0 -5px; } +.widget-slider-graphic .widget-graphic-cover > .row > [class*="col-"] { height: 100%; padding: 0 5px; } +.widget-slider-graphic .graphic-cover-item { aspect-ratio: auto; height: var(--cover-ratio, auto); } +.widget-slider-graphic .graphic-cover-item { position: relative; overflow: hidden; min-height: 0; border-radius: var(--aksrt-widget-radius, 8px); background: #111; color: #fff; } +.widget-slider-graphic .graphic-cover-item img, .widget-slider-graphic .graphic-cover-item video { display: block; width: 100%; height: 100%; object-fit: cover; } +.widget-slider-graphic .graphic-cover-title { position: absolute; right: 10px; bottom: 10px; left: 10px; z-index: 1; } +.new-swiper.slide-widget { overflow: hidden; position: relative; } +.new-swiper.slide-widget .swiper-wrapper { height: var(--pc-height, auto); } +.new-swiper.slide-widget.scale-height { height: 0; padding-bottom: var(--scale-height, 35%); } +.new-swiper.slide-widget.scale-height .swiper-wrapper { position: absolute; inset: 0; height: 100%; } +.new-swiper.slide-widget .swiper-slide > a, .new-swiper.slide-widget .swiper-slide > span { position: relative; display: block; width: 100%; height: 100%; } +.new-swiper.slide-widget .swiper-slide img, .new-swiper.slide-widget .swiper-slide video { width: 100%; height: 100%; object-fit: cover; } +.new-swiper.slide-widget .slide-layer { z-index: 1; } +.new-swiper.slide-widget .slide-text { z-index: 2; } +.new-swiper.slide-widget .slide-text.left-bottom { left: 8%; bottom: 10%; } +.new-swiper.slide-widget .slide-text.left-conter { left: 8%; top: 50%; transform: translateY(-50%); } +.new-swiper.slide-widget .slide-text.conter-conter, .new-swiper.slide-widget .slide-text.conter-bottom { left: 50%; transform: translateX(-50%); text-align: center; } +.new-swiper.slide-widget .slide-text.conter-conter { top: 50%; transform: translate(-50%, -50%); } +.new-swiper.slide-widget .slide-text.conter-bottom { bottom: 10%; } +.new-swiper.slide-widget .slide-text.right-conter { right: 8%; top: 50%; transform: translateY(-50%); text-align: right; } +.new-swiper.slide-widget .slide-text.right-bottom { right: 8%; bottom: 10%; text-align: right; } +.new-swiper.slide-widget .slide-title { font-size: var(--text-size-pc, 30px); } +.new-swiper.slide-widget .slide-desc { margin-top: .6em; font-size: 60%; } +@media (max-width: 767px) { + .new-swiper.slide-widget .swiper-wrapper { height: var(--m-height, auto); } + .new-swiper.slide-widget .slide-title { font-size: var(--text-size-m, 20px); } + .widget-slider-graphic { display: block; } + .widget-slider-graphic.m-default { display: flex; } + .widget-slider-graphic.m-default .slider-col { width: var(--layout-ratio, 60%); } + .widget-slider-graphic.m-default .cover-col { width: calc(100% - var(--layout-ratio, 60%)); } + .widget-slider-graphic .slider-col, .widget-slider-graphic .cover-col { width: 100%; } + .widget-slider-graphic.m-hide_slide .slider-col, .widget-slider-graphic.m-hide_cover .cover-col { display: none; } + .widget-slider-graphic .cover-col { width: 100%; } + .widget-slider-graphic .widget-graphic-cover > .row { height: auto; } + .widget-slider-graphic .widget-graphic-cover > .row > [class*="col-"] { height: auto; } + .widget-slider-graphic .graphic-cover-item { height: auto; aspect-ratio: 100 / var(--m-cover-ratio, 45); } + .widget-slider-graphic .cover-col .graphic-text { font-size: var(--font-size-sm, 14px); font-weight: var(--font-weight-sm, normal); color: var(--color-sm, inherit); } +} @keyframes aksrt-widget-fade { from { opacity: 0; } }@keyframes aksrt-widget-slideup { from { opacity:0;transform:translateY(25px); } }@keyframes aksrt-widget-slidedown { from { opacity:0;transform:translateY(-25px); } }@keyframes aksrt-widget-slideright { from { opacity:0;transform:translateX(-25px); } }@keyframes aksrt-widget-slideleft { from { opacity:0;transform:translateX(25px); } }@keyframes aksrt-widget-zoomin { from { opacity:0;transform:scale(.9); } }@keyframes aksrt-widget-zoomout { from { opacity:0;transform:scale(1.1); } } diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index 60f1ddb6..3a8ffa24 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -191,18 +191,25 @@ function initTabs(tabs) { if (!once(tabs, 'Tabs')) return; - tabs.addEventListener('keydown', function (event) { moveTabFocus(event, tabs); }); + tabs.addEventListener('keydown', function (event) { moveTabFocus(event, tabs); }); tabs.addEventListener('click', function (event) { - var button = event.target.closest('[data-segment]'); + var button = event.target.closest('[role="tab"][data-segment]'); if (!button) return; + event.preventDefault(); tabs.querySelectorAll('[role="tab"]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); tab.setAttribute('tabindex', tab === button ? '0' : '-1'); + if (tab.parentElement) tab.parentElement.classList.toggle('active', tab === button); }); - var panel = tabs.querySelector('[data-widget-items]'); + tabs.querySelectorAll('.tab-content > .tab-pane').forEach(function (item) { + var active = item.id === button.getAttribute('aria-controls'); + item.classList.toggle('active', active); item.classList.toggle('in', active); + }); + var panel = tabs.querySelector('[data-widget-items][data-segment="' + button.dataset.segment + '"]'); + if (!panel) panel = tabs.querySelector('[data-widget-items]'); panel.dataset.segment = button.dataset.segment; panel.setAttribute('aria-labelledby', button.id); - loadItems(panel, 1, false); + loadItems(panel, 1, false); }); } @@ -261,7 +268,7 @@ scope.querySelectorAll('.aksrt-widget-toc').forEach(initToc); scope.querySelectorAll('.aksrt-widget-quote').forEach(initQuote); scope.querySelectorAll('[data-widget-items]').forEach(initItems); - scope.querySelectorAll('.aksrt-widget-tabs').forEach(initTabs); + scope.querySelectorAll('.aksrt-widget-tabs, .widget-tab-post').forEach(initTabs); scope.querySelectorAll('.aksrt-faq').forEach(initFaq); scope.querySelectorAll('.aksrt-feature-intro').forEach(initFeatures); scope.querySelectorAll('[data-count-up]').forEach(initCount); diff --git a/src/WidgetSystem/FieldSchema.php b/src/WidgetSystem/FieldSchema.php index 099ebab5..15992fa5 100644 --- a/src/WidgetSystem/FieldSchema.php +++ b/src/WidgetSystem/FieldSchema.php @@ -13,7 +13,7 @@ final class FieldSchema { public static function supported_types(): array { return [ 'text', 'textarea', 'number', 'spinner', 'slider', 'switcher', 'radio', - 'button_set', 'select', 'checkbox', 'color', 'palette', 'icon', 'upload', + 'button_set', 'select', 'checkbox', 'color', 'palette', 'image_select', 'icon', 'upload', 'link', 'spacing', 'background', 'group', 'fieldset', 'accordion', 'content', 'subheading', 'submessage', ]; @@ -59,6 +59,7 @@ final class FieldSchema { case 'button_set': case 'select': case 'palette': + case 'image_select': return self::choice($field, $value); case 'checkbox': return self::checkbox($field, $value); @@ -86,6 +87,11 @@ final class FieldSchema { if (($field['sanitize'] ?? '') === 'ids') { return implode(',', array_values(array_unique(array_filter(array_map('absint', preg_split('/[,,\s]+/', (string) $value)))))); } + if (($field['sanitize'] ?? '') === 'reference_ids') { + $tokens = preg_split('/[,,\s]+/', trim((string) $value), -1, PREG_SPLIT_NO_EMPTY); + $tokens = array_map(static fn($token): string => preg_match('/^-?\d+$/', (string) $token) ? (string) (int) $token : '', $tokens ?: []); + return implode(',', array_values(array_unique(array_filter($tokens, static fn($token): bool => $token !== '')))); + } return !empty($field['allow_html']) ? wp_kses_post((string) $value) : sanitize_text_field((string) $value); } } diff --git a/src/WidgetSystem/FormRenderer.php b/src/WidgetSystem/FormRenderer.php index 45cb83d2..c7ebd254 100644 --- a/src/WidgetSystem/FormRenderer.php +++ b/src/WidgetSystem/FormRenderer.php @@ -51,6 +51,7 @@ final class FormRenderer { case 'radio': case 'button_set': case 'palette': + case 'image_select': self::radios($field_id, $field_name, (array) ($field['options'] ?? []), (string) $value, $type); break; case 'select': @@ -65,10 +66,11 @@ final class FormRenderer { self::number($field_id, $field_name, $field, $value, $type); break; case 'textarea': - echo ''; + $rows = max(1, (int) ($field['attributes']['rows'] ?? 4)); + echo ''; break; case 'upload': - self::upload($field_id, $field_name, (string) $value); + self::upload($field_id, $field_name, (string) $value, (string) ($field['library'] ?? '')); break; case 'color': echo ''; @@ -113,7 +115,13 @@ final class FormRenderer { echo '
'; foreach ($options as $key => $label) { $option_id = $id . '-' . sanitize_html_class((string) $key); - echo ''; + $image = is_array($label) ? (string) ($label['image'] ?? $label['url'] ?? '') : (string) $label; + $text = is_array($label) ? (string) ($label['label'] ?? $label['title'] ?? $key) : (string) $label; + if ($type === 'image_select' && $text === $image) $text = (string) $key; + $body = $type === 'image_select' && filter_var($image, FILTER_VALIDATE_URL) + ? '' . esc_attr($text) . '' . esc_html($text) . '' + : esc_html($text); + echo ''; } echo '
'; } @@ -148,13 +156,14 @@ final class FormRenderer { echo '
' . esc_html((string) $value) . '
'; } - private static function upload(string $id, string $name, string $value): void { - echo '
'; + private static function upload(string $id, string $name, string $value, string $library = ''): void { + $library = in_array($library, ['image', 'video'], true) ? $library : ''; + echo '
'; if ($value !== '') { echo ''; } echo ''; - echo '
'; + echo '
'; } private static function link(SchemaWidget $widget, string $key, array $value): void { @@ -207,7 +216,11 @@ final class FormRenderer { } private static function group_row(SchemaWidget $widget, array $field, array $row, string $key, string $index): void { - echo '
' . esc_html__('项目', 'aksrt') . '
'; + $prefix = (string) ($field['item_label'] ?? $field['label'] ?? __('项目', 'aksrt')); + $number = ctype_digit($index) ? ((int) $index + 1) : 0; + $title = $prefix . ($number ? ' ' . $number : ''); + if (!empty($row['title'])) $title .= ':' . strip_tags((string) $row['title']); + echo '
' . esc_html($title) . '
'; foreach ((array) ($field['fields'] ?? []) as $child) { $child_id = (string) ($child['id'] ?? ''); self::field($widget, $child, $row[$child_id] ?? ($child['default'] ?? ''), $key . '[' . $index . ']'); diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index 1ae88e92..d6a5981c 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -23,6 +23,13 @@ final class FrontendRenderer { } AssetManager::enqueue_for($definition); + if (in_array($definition->slug(), ['tab_posts', 'slider', 'slider_media_combo'], true)) { + echo (string) ($args['before_widget'] ?? ''); + echo self::reference_title($instance); + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo (string) ($args['after_widget'] ?? ''); + return; + } $classes = ['aksrt-widget', 'aksrt-panel-wrap', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; if ($definition->size() === 'sidebar' && $definition->slug() !== 'user_card') { $classes[] = 'aksrt-panel'; @@ -62,7 +69,7 @@ final class FrontendRenderer { if ($title === '') { return ''; } - $safe_title = esc_html($title); + $safe_title = wp_kses_post($title); $highlight = trim((string) ($instance['title_highlight'] ?? '')); if ($highlight !== '') { $safe_highlight = esc_html($highlight); @@ -70,7 +77,7 @@ final class FrontendRenderer { } $html = '

' . nl2br($safe_title) . '

'; if (!empty($instance['subtitle'])) { - $html .= '

' . nl2br(esc_html((string) $instance['subtitle'])) . '

'; + $html .= '

' . nl2br(wp_kses_post((string) $instance['subtitle'])) . '

'; } $html .= '
'; } + private static function reference_title(array $instance): string { + $title = trim((string) ($instance['title'] ?? '')); + if ($title === '') return ''; + $title = wp_kses_post($title); + $highlight = trim((string) ($instance['title_highlight'] ?? '')); + if ($highlight !== '') $title = str_replace(esc_html($highlight), '' . esc_html($highlight) . '', $title); + $subtitle = !empty($instance['subtitle']) ? wp_kses_post((string) $instance['subtitle']) : ''; + if (($instance['title_style'] ?? '') === 'big') { + $links = ''; + foreach (['title_link', 'title_link2', 'title_link3'] as $index => $key) { $link = RendererHelpers::link((array) ($instance[$key] ?? [])); if ($link['url'] && $link['text']) $links .= '' . esc_html($link['text']) . ''; } + return '

' . $title . '

' . ($subtitle ? '

' . $subtitle . '

' : '') . ($links ? '
' . $links . '
' : '') . '
'; + } + $link = RendererHelpers::link((array) ($instance['title_link'] ?? [])); + $more = $link['url'] ? '' : ''; + return '
' . $title . ($subtitle ? '' . $subtitle . '' : '') . $more . '
'; + } + private static function style(array $instance): string { $properties = []; foreach (['layout_pin_pc' => 'space', 'layout_pin_m' => 'space-m'] as $key => $prefix) { diff --git a/src/WidgetSystem/PostQuery.php b/src/WidgetSystem/PostQuery.php index 633cbcd7..aaf81024 100644 --- a/src/WidgetSystem/PostQuery.php +++ b/src/WidgetSystem/PostQuery.php @@ -8,8 +8,6 @@ defined('ABSPATH') || exit; * Builds post queries exclusively from normalized, saved widget settings. */ final class PostQuery { - private const PAY_TYPES = ['reading', 'download', 'gallery', 'video', 'vip']; - public static function build(array $instance, int $page = 1, array $segment = []): \WP_Query { return new \WP_Query(self::arguments($instance, $page, $segment)); } @@ -26,28 +24,60 @@ final class PostQuery { 'ignore_sticky_posts' => true, 'no_found_rows' => false, ]; + if (array_key_exists('paginate', $config) && in_array((string) $config['paginate'], ['', 'none'], true)) { + $args['no_found_rows'] = true; + $args['paged'] = 1; + } - $categories = RendererHelpers::ids((string) ($config['category_ids'] ?? '')); - if (!empty($config['category_id'])) { - $categories = [(int) $config['category_id']]; - } - if ($categories) { - $args['category__in'] = $categories; - } - $tags = RendererHelpers::ids((string) ($config['tag_ids'] ?? '')); + // The modern tab widget uses Zibll's signed `cat` syntax. Legacy + // category_ids remains available only to the unrelated generic posts + // widgets; a tab segment must never fall back to it. + $is_reference_segment = array_key_exists('cat', $segment) || array_key_exists('topics', $segment) || array_key_exists('zibpay_type', $segment); + $cat_raw = array_key_exists('cat', $config) ? (string) $config['cat'] : ($is_reference_segment ? '' : (string) ($config['category_ids'] ?? '')); + $categories = self::signed_ids($cat_raw); + if (!empty($config['category_id'])) $categories = [(int) $config['category_id']]; + $cat_in = array_values(array_filter($categories, static fn(int $id): bool => $id > 0)); + $cat_not = array_values(array_map('abs', array_filter($categories, static fn(int $id): bool => $id < 0))); + if ($cat_in) $args['category__in'] = $cat_in; + if ($cat_not) $args['category__not_in'] = $cat_not; + $tags = $is_reference_segment ? [] : RendererHelpers::ids((string) ($config['tag_ids'] ?? '')); if ($tags) { $args['tag__in'] = $tags; } + $topics_raw = (string) ($config['topics'] ?? ''); + $topics = self::signed_ids($topics_raw); + if ($topics) { + $tax_query = []; + $topic_in = array_values(array_filter($topics, static fn(int $id): bool => $id > 0)); + $topic_not = array_values(array_map('abs', array_filter($topics, static fn(int $id): bool => $id < 0))); + if ($topic_in) $tax_query[] = ['taxonomy' => 'topics', 'field' => 'term_id', 'terms' => $topic_in, 'include_children' => true]; + if ($topic_not) $tax_query[] = ['taxonomy' => 'topics', 'field' => 'term_id', 'terms' => $topic_not, 'operator' => 'NOT IN', 'include_children' => true]; + if (count($tax_query) > 1) $tax_query = array_merge(['relation' => 'AND'], $tax_query); + if ($tax_query) $args['tax_query'] = $tax_query; + } $days = max(0, (int) ($config['limit_day'] ?? 0)); if ($days > 0) { - $args['date_query'] = [['after' => gmdate('Y-m-d', time() - ($days * DAY_IN_SECONDS)), 'inclusive' => true]]; + $now = function_exists('current_time') ? current_time('timestamp') : time(); + $format = static function (int $timestamp): string { + return function_exists('wp_date') ? wp_date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s', $timestamp); + }; + $args['date_query'] = [['after' => $format($now - ($days * DAY_IN_SECONDS)), 'before' => $format($now), 'inclusive' => true]]; } self::apply_order($args, (string) ($config['orderby'] ?? 'date'), (string) ($config['order'] ?? 'DESC')); - self::apply_pay_type($args, (string) ($config['pay_type'] ?? '')); + $pay = array_key_exists('zibpay_type', $config) ? $config['zibpay_type'] : ($is_reference_segment ? '' : ($config['pay_type'] ?? '')); + self::apply_pay_type($args, $pay); return $args; } + /** @return list */ + private static function signed_ids(string $raw): array { + $tokens = preg_split('/[,,\s]+/', trim($raw), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $out = []; + foreach ($tokens as $token) if (preg_match('/^-?\d+$/', (string) $token)) $out[] = (int) $token; + return array_values(array_unique(array_filter($out, static fn(int $id): bool => $id !== 0))); + } + public static function segment(array $instance, string $segment): array { $tabs = array_values((array) ($instance['tabs'] ?? [])); if (!$tabs) { @@ -61,16 +91,19 @@ final class PostQuery { $args['order'] = strtoupper($order) === 'ASC' ? 'ASC' : 'DESC'; $meta = [ 'views' => class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY : 'aksrt_post_views', - 'likes' => '_aksrt_like_count', - 'favorites' => '_aksrt_favorite_count', + 'like' => '_aksrt_like_count', 'likes' => '_aksrt_like_count', + 'favorite' => '_aksrt_favorite_count', 'favorites' => '_aksrt_favorite_count', 'downloads' => class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY : '_aksrt_res_download_count', + 'zibpay_price' => class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::PAY_PRICE_KEY : '_aksrt_pay_price', + 'zibpay_points_price' => '_aksrt_pay_points_price', + 'sales_volume' => class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY : '_aksrt_sales_volume', ]; if (isset($meta[$orderby])) { $args['meta_key'] = $meta[$orderby]; $args['orderby'] = 'meta_value_num'; return; } - if ($orderby === 'comments') { + if ($orderby === 'comments' || $orderby === 'comment_count') { $args['orderby'] = 'comment_count'; } elseif (in_array($orderby, ['date', 'modified', 'rand'], true)) { $args['orderby'] = $orderby; @@ -79,16 +112,22 @@ final class PostQuery { } } - private static function apply_pay_type(array &$args, string $pay_type): void { - if (!in_array($pay_type, self::PAY_TYPES, true)) { - return; - } + private static function apply_pay_type(array &$args, $pay_type): void { + $values = is_array($pay_type) ? array_map('strval', $pay_type) : array_filter(array_map('trim', preg_split('/[,,\s]+/', (string) $pay_type) ?: [])); + $map = ['1' => 'reading', '2' => 'download', '5' => 'gallery', '6' => 'video', 'reading' => 'reading', 'download' => 'download', 'gallery' => 'gallery', 'video' => 'video', 'vip' => 'vip']; + $values = array_values(array_unique(array_filter(array_map(static fn($value): string => $map[(string) $value] ?? '', $values)))); + if (!$values) return; $type_key = class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::PAY_TYPE_KEY : '_aksrt_pay_type'; $meta_key = class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::PAY_META_KEY : '_aksrt_pay'; - $args['meta_query'] = [[ + $pay_query = [ 'relation' => 'OR', - ['key' => $type_key, 'value' => $pay_type, 'compare' => '='], - ['key' => $meta_key, 'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"', 'compare' => 'LIKE'], - ]]; + ['key' => $type_key, 'value' => $values, 'compare' => 'IN'], + ]; + // Some AKSRT installations only have the structured pay array. LIKE + // accepts one scalar per clause, so emit a clause for each allowed type. + foreach ($values as $value) { + $pay_query[] = ['key' => $meta_key, 'value' => '"' . $value . '"', 'compare' => 'LIKE']; + } + $args['meta_query'] = [$pay_query]; } } diff --git a/src/WidgetSystem/PostRenderer.php b/src/WidgetSystem/PostRenderer.php index 47579440..39e91ba5 100644 --- a/src/WidgetSystem/PostRenderer.php +++ b/src/WidgetSystem/PostRenderer.php @@ -26,15 +26,17 @@ final class PostRenderer { public static function items(\WP_Query $query, array $instance, string $slug, int $page = 1): string { if (!$query->have_posts()) { - return RendererHelpers::empty_state(__('暂无文章', 'aksrt')); + return RendererHelpers::empty_state(__('暂无内容', 'aksrt')); } $style = (string) ($instance['style'] ?? 'card'); $mode = in_array($style, ['list', 'card', 'mini'], true) ? $style : 'card'; $config = [ 'type' => $mode === 'card' ? 'card' : 'list', - 'show_number' => $slug === 'hot_posts', - 'show_thumb' => $slug !== 'hot_posts' || !array_key_exists('show_thumbnail', $instance) || !empty($instance['show_thumbnail']), - 'show_meta' => true, + 'show_number' => $slug === 'hot_posts' || in_array('show_number', (array) ($instance['mini_opt'] ?? []), true), + 'show_thumb' => $slug === 'tab_posts' + ? in_array('show_thumb', (array) ($instance['mini_opt'] ?? []), true) + : ($slug !== 'hot_posts' || !array_key_exists('show_thumbnail', $instance) || !empty($instance['show_thumbnail'])), + 'show_meta' => $slug !== 'tab_posts' || in_array('show_meta', (array) ($instance['mini_opt'] ?? []), true), 'number_offset' => ($page - 1) * max(1, (int) ($instance['count'] ?? 8)), 'wrapper_class' => 'aksrt-widget-posts aksrt-widget-posts--' . $slug, 'widget_style' => $mode, @@ -77,11 +79,14 @@ final class PostRenderer { } private static function pagination(array $instance, int $page, int $max_pages): string { - $mode = (string) ($instance['paginate'] ?? 'none'); - if ($max_pages <= 1 || $mode === 'none') { + $mode = (string) ($instance['paginate'] ?? ''); + if ($max_pages <= 1) { return ''; } - if ($mode === 'numbered') { + if ($mode === '' || $mode === 'none') { + return '
'; + } + if ($mode === 'number' || $mode === 'numbered') { $start = max(1, min($page - 2, $max_pages - 4)); $end = min($max_pages, max(5, $page + 2)); $html = ''; } - $label = $mode === 'infinite' ? __('继续加载', 'aksrt') : __('加载更多', 'aksrt'); - $class = $mode === 'infinite' ? 'aksrt-widget-load-more ias-btn lazyload' : 'aksrt-widget-load-more'; - $action = $mode === 'infinite' ? ' lazyload-action="ias"' : ''; + $label = __('加载更多', 'aksrt'); + $class = 'aksrt-widget-load-more'; + $action = ''; return ''; } } diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php index 7532040d..8a631eb3 100644 --- a/src/WidgetSystem/Renderers/ContentRenderer.php +++ b/src/WidgetSystem/Renderers/ContentRenderer.php @@ -104,20 +104,38 @@ final class ContentRenderer { } private static function tab_posts(array $instance, array $context): string { - $tabs = array_values((array) $instance['tabs']); + $style = in_array(($instance['style'] ?? 'mini'), ['list', 'card', 'mini'], true) ? (string) $instance['style'] : 'mini'; + $tabs = array_values((array) ($instance['tabs'] ?? [])); if (!$tabs) { return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt')); } $buttons = ''; $key = sanitize_html_class((string) ($context['widget_id'] ?? 'preview')) ?: 'preview'; $panel_id = 'aksrt-tab-panel-' . $key; + $ajax_url = rest_url('aksrt/v1/widgets/tab_posts/items'); $first_tab_id = 'aksrt-tab-' . $key . '-0'; + $placeholder_type = $style === 'card' ? 'card' : 'lists'; + $placeholder = $style === 'mini' + ? str_repeat('
', max(1, (int) ($instance['count'] ?? 6))) + : aksrt_render_placeholder($placeholder_type, max(1, (int) ($instance['count'] ?? 6))); + $panels = ''; + $visible_index = 0; foreach ($tabs as $index => $tab) { - $tab_id = 'aksrt-tab-' . $key . '-' . $index; - $buttons .= ''; + if (!is_array($tab) || trim((string) ($tab['title'] ?? '')) === '') continue; + $tab_id = 'aksrt-tab-' . $key . '-' . $visible_index; + $selected = $visible_index === 0; + $buttons .= '
  • ' . wp_kses_post((string) $tab['title']) . '
  • '; + if ($selected) { + $payload = PostRenderer::payload($instance, 'tab_posts', 1, (string) $index); + $body = $payload['html'] . $payload['paginate_html'] . ''; + } else { + $body = ''; + } + $panels .= '
    ' . $body . '
    '; + $visible_index++; } - $payload = PostRenderer::payload($instance, 'tab_posts', 1, '0'); - return '
    ' . $buttons . '
    ' . $payload['html'] . $payload['paginate_html'] . '
    '; + if ($buttons === '') return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt')); + return '
      ' . $buttons . '
    ' . $panels . '
    '; } private static function post_pay(array $instance): string { diff --git a/src/WidgetSystem/Renderers/SliderRenderer.php b/src/WidgetSystem/Renderers/SliderRenderer.php index c9f10431..14b30875 100644 --- a/src/WidgetSystem/Renderers/SliderRenderer.php +++ b/src/WidgetSystem/Renderers/SliderRenderer.php @@ -6,56 +6,90 @@ use AKSRT\WidgetSystem\RendererHelpers; defined('ABSPATH') || exit; -/** Render full sliders and the slider/media combination. */ +/** Reference-compatible renderer for the modern Zibll slider widgets. */ final class SliderRenderer { public static function render(string $slug, array $instance, array $context = []): string { unset($context); - $slides = ''; - foreach ((array) $instance['slides'] as $slide) { - if (!is_array($slide) || (empty($slide['background']) && empty($slide['background_video']))) { - continue; - } - $link = RendererHelpers::link((array) ($slide['link'] ?? [])); - if (!empty($slide['background_video'])) { - $video = esc_url((string) $slide['background_video']); - $video_attr = aksrt_is_lazy('lazy_sider') - ? ' class="lazyload" src="" data-src="' . $video . '"' - : ' src="' . $video . '"'; - $media = ''; - } else { - $media = RendererHelpers::image((string) $slide['background'], (string) ($slide['title'] ?? ''), '', 'lazy_sider', aksrt_get_lazy_thumb('lg')); - } + return $slug === 'slider_media_combo' ? self::combo($instance) : self::slider((array) ($instance['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(); + $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 = '
    '; } + else $media = '' . esc_attr($alt) . ''; $layers = ''; - foreach ((array) ($slide['layers'] ?? []) as $layer) { - if (!empty($layer['image'])) { - $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) . '"'; + $class = !empty($layer['free_size']) ? ' slide-layer text-' . esc_attr((string) ($layer['align'] ?? 'center')) : ''; + $layers .= '
    ' . esc_attr($alt) . '
    '; } - $copy = '

    ' . esc_html((string) ($slide['title'] ?? '')) . '

    ' . esc_html((string) ($slide['description'] ?? '')) . '

    '; - $body = $media . $layers . $copy; - if ($link['url']) { - $body = '' . $body . ''; - } - $slides .= '
    ' . $body . '
    '; + $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 = '
    ' . wp_kses_post((string) $text['title']) . '
    ' . (!empty($text['desc']) ? '
    ' . wp_kses_post((string) $text['desc']) . '
    ' : '') . '
    '; if (!empty($text['parallax'])) $copy = '
    ' . $copy . '
    '; } + $body = $media . $layers . $copy; $link = RendererHelpers::link((array) ($slide['link'] ?? [])); if ($link['url'] !== '') $body = '' . $body . ''; + $items .= '
    ' . $body . '
    '; $count++; } - if ($slides === '') { - return RendererHelpers::empty_state(__('请配置幻灯片', 'aksrt')); + 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 = '
    ' . $items . '
    '; if (!empty($option['button']) && $count > 1) $html .= '
    '; if (!empty($option['pagination']) && $count > 1) $html .= '
    '; return '
    ' . $html . '
    '; + } + + 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) : ''; + $covers = ''; + $is_mobile = function_exists('wp_is_mobile') && wp_is_mobile(); + $cover_list = array_values((array) ($instance['covers'] ?? [])); + $is_row = count($cover_list) > 1; + $pc_row = max(1, min(3, (int) ($instance['pc_row'] ?? 2))); + $m_row = max(1, min(3, (int) ($instance['m_row'] ?? 2))); + $row_class = 'col-sm-' . (int) (12 / $pc_row) . ($m_row > 1 ? ' col-xs-' . (int) (12 / $m_row) : ''); + 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'] ?? '')); + if ($image === '' && ($video === '' || $is_mobile)) continue; + $media = $video !== '' && !$is_mobile ? '' : '' . esc_attr(wp_strip_all_tags((string) ($cover['title'] ?? ''))) . ''; + $mask = max(0, min(90, (int) ($instance['mask_opacity'] ?? 0))) / 100; + $card = $media . ($mask ? '' : '') . (!empty($cover['title']) ? '
    ' . wp_kses_post((string) $cover['title']) . '
    ' : ''); + $link = RendererHelpers::link((array) ($cover['link'] ?? [])); + $card = $link['url'] !== '' ? '' . $card . '' : '' . $card . ''; + $covers .= '
    ' . $card . '
    '; } - $slider = '
    ' . $slides . '
    '; - if (!empty($instance['button'])) { - $slider .= ''; + if ($covers !== '') { + $cover_style = '--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 .= '--font-weight:bold;--font-weight-sm:bold;'; + if (!empty($instance['font_color'])) $cover_style .= '--color:' . $instance['font_color'] . ';--color-sm:' . $instance['font_color'] . ';'; + $covers = '
    ' . ($is_row ? '
    ' : '') . $covers . ($is_row ? '
    ' : '') . '
    '; } - if (!empty($instance['pagination'])) { - $slider .= '
    '; - } - $slider .= '
    '; - if ($slug !== 'slider_media_combo') { - return $slider; - } - $cards = ''; - foreach ((array) ($instance['side_cards'] ?? []) as $card) { - $cards .= RendererHelpers::card_media((array) $card); - } - return '
    ' . $slider . '
    '; + 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 = max(1, (int) ($instance['pc_row'] ?? 2)); + $columns = max(1, (int) ($instance['pc_col'] ?? 2)); + $base = $layout < 100 ? (($layout * $slide_scale) / (100 - $layout)) : 0; + $cover_ratio = (($base - (($columns - 1) * 3)) / $columns) * $rows; + $style = '--layout-ratio:' . $layout . '%;--cover-ratio:' . $cover_ratio . '%;--m-cover-ratio:' . (int) ($instance['m_cover_ratio'] ?? 45) . '%;--cover-cols:' . $rows . ';'; + $class = 'flex widget-slider-graphic m-' . sanitize_html_class((string) ($instance['m_layout_type'] ?? 'vertical')); + $html = '
    '; + if ($slider !== '') $html .= '
    ' . $slider . '
    '; + if ($covers !== '') $html .= '
    ' . $covers . '
    '; + return $html . '
    '; } } diff --git a/src/WidgetSystem/WidgetCatalog.php b/src/WidgetSystem/WidgetCatalog.php index ca83f142..107e5b6a 100644 --- a/src/WidgetSystem/WidgetCatalog.php +++ b/src/WidgetSystem/WidgetCatalog.php @@ -35,7 +35,7 @@ final class WidgetCatalog { ['hot_posts', '热榜文章', '按浏览、评论、收藏等维度排行', 'sidebar'], ['post_pay', '付费购买', '显示当前文章的知识付费购买模块', 'sidebar'], ['posts', '文章列表', '多条件筛选和多样式文章列表', 'wide'], - ['tab_posts', '多栏目文章', '通过多个栏目切换文章列表', 'wide'], + ['tab_posts', '多栏目文章(新)', '通过多个栏目切换文章列表', 'wide'], ['oneline_posts', '横向滚动文章', '单行横向滚动文章列表', 'wide'], ], 'knowledge_product' => [ @@ -48,7 +48,7 @@ final class WidgetCatalog { ['tilt_cases', '知识产品 3D 展示', '带 3D 交互的知识产品案例展示', 'wide'], ], 'slider' => [ - ['slider', '幻灯片', '支持图片、视频、文字和视差图层', 'wide'], + ['slider', '幻灯片(新)', '支持图片、视频、文字和视差图层', 'wide'], ['slider_media_combo', '幻灯片加图文视频卡片', '幻灯片与图文视频卡片组合布局', 'wide'], ], 'user' => [ @@ -70,7 +70,7 @@ final class WidgetCatalog { 'renderer' => $slug, 'size' => $size, 'common' => [ - 'title' => !in_array($slug, ['post_pay', 'layout_gap'], true), + 'title' => !in_array($slug, ['post_pay', 'layout_gap', 'slider', 'slider_media_combo'], true), 'affix' => $size === 'sidebar', ], 'fields' => WidgetSchemas::for($slug), diff --git a/src/WidgetSystem/WidgetSchemas.php b/src/WidgetSystem/WidgetSchemas.php index 1ce5567f..abbec296 100644 --- a/src/WidgetSystem/WidgetSchemas.php +++ b/src/WidgetSystem/WidgetSchemas.php @@ -145,19 +145,45 @@ final class WidgetSchemas { $fields[] = ['id' => 'autoplay', 'type' => 'switcher', 'label' => '自动滚动', 'default' => true]; } if ($slug === 'tab_posts') { - $fields[] = [ - 'id' => 'tabs', 'type' => 'group', 'label' => '栏目', 'max_items' => 10, - 'fields' => [ - ['id' => 'title', 'type' => 'text', 'label' => '栏目标题', 'default' => ''], - ['id' => 'category_id', 'type' => 'number', 'label' => '分类 ID', 'default' => 0, 'min' => 0], - ['id' => 'tag_ids', 'type' => 'text', 'label' => '标签 ID', 'default' => '', 'sanitize' => 'ids'], - ['id' => 'pay_type', 'type' => 'select', 'label' => '付费类型', 'default' => '', 'options' => self::pay_types()], - ], - ]; + return self::tab_posts_fields(); } return $fields; } + private static function tab_posts_fields(): array { + return [ + ['id' => 'style', 'type' => 'radio', 'label' => '列表样式', 'default' => 'mini', 'options' => ['list' => '列表样式', 'card' => '卡片样式', 'mini' => 'mini列表']], + ['id' => 'mini_opt', 'type' => 'checkbox', 'label' => 'mini列表配置', 'default' => [], 'dependency' => ['style', '==', 'mini'], 'options' => ['show_thumb' => '显示缩略图', 'show_number' => '显示编号(开启翻页后,只在第一页有效)', 'show_meta' => '显示作者、时间、点赞等信息']], + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量', 'default' => 6, 'min' => 4, 'max' => 20, 'step' => 1], + ['id' => 'paginate', 'type' => 'radio', 'label' => '翻页按钮', 'default' => '', 'options' => ['' => '不翻页', 'ajax' => 'AJAX追加列表翻页', 'number' => '数字翻页按钮']], + [ + 'id' => 'tabs', 'type' => 'group', 'label' => '栏目', 'max_items' => 10, + 'default' => [ + ['title' => '热门推荐', 'orderby' => 'views'], + ['title' => '最近更新', 'orderby' => 'modified'], + ['title' => '猜你喜欢', 'orderby' => 'rand'], + ], + 'fields' => [ + ['id' => 'title', 'type' => 'textarea', 'label' => '标题(必填)', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1]], + ['id' => 'cat', 'type' => 'text', 'label' => '分类限制', 'default' => '', 'sanitize' => 'reference_ids'], + ['id' => 'topics', 'type' => 'text', 'label' => '专题限制', 'default' => '', 'sanitize' => 'reference_ids', 'description' => '多个 ID 用英文逗号隔开,支持负数排除。'], + ['id' => 'zibpay_type', 'type' => 'checkbox', 'label' => '商品类型筛选', 'default' => [], 'options' => ['1' => '付费阅读', '2' => '付费下载', '5' => '付费图片', '6' => '付费视频']], + ['id' => 'limit_day', 'type' => 'spinner', 'label' => '发布时间限制', 'default' => 0, 'min' => 0, 'step' => 5, 'max' => 3650], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序方式', 'default' => '', 'options' => self::posts_orderby()], + ['id' => 'order', 'type' => 'radio', 'label' => '顺序', 'default' => 'desc', 'options' => ['desc' => '降序', 'asc' => '升序']], + ], + ], + ]; + } + + private static function posts_orderby(): array { + return [ + '' => '默认排序', 'modified' => '更新时间', 'date' => '发布时间', 'views' => '浏览数量', + 'like' => '点赞数量', 'comment_count' => '评论数量', 'favorite' => '收藏数量', + 'zibpay_price' => '销售价格', 'zibpay_points_price' => '积分售价', 'sales_volume' => '销售数量', 'rand' => '随机排序', + ]; + } + private static function post_query_fields(): array { return [ ['id' => 'category_ids', 'type' => 'text', 'label' => '分类 ID', 'default' => '', 'sanitize' => 'ids'], @@ -206,31 +232,81 @@ final class WidgetSchemas { } private static function slider(string $slug): array { + $asset_uri = function_exists('get_template_directory_uri') ? get_template_directory_uri() . '/assets/img/' : ''; + $align_uri = function_exists('get_template_directory_uri') ? get_template_directory_uri() . '/assets/images/slider-align/' : ''; + $slide_fields = [ + ['id' => 'background', 'type' => 'upload', 'label' => '图片背景', 'default' => ''], + ['id' => 'background_video', 'type' => 'upload', 'label' => '视频背景', 'default' => '', 'description' => '图片背景、视频背景至少二选一;PC 端视频优先,移动端只显示图片。'], + ['id' => 'hide', 'type' => 'radio', 'label' => '显示规则', 'default' => '', 'options' => ['' => '全部显示', 'pc' => 'PC端不显示', 'm' => '移动端不显示']], + ['id' => 'link', 'type' => 'link', 'label' => '跳转链接', 'default' => []], + ['id' => 'image_layer', 'type' => 'group', 'label' => '叠加图层', 'max_items' => 12, 'fields' => [ + ['id' => 'image', 'type' => 'upload', 'label' => '图层图片', 'default' => ''], + ['id' => 'free_size', 'type' => 'switcher', 'label' => '自由尺寸', 'default' => false], + ['id' => 'align', 'type' => 'radio', 'label' => '图层对齐', 'default' => 'center', 'options' => ['left' => '靠左显示', 'center' => '居中显示', 'right' => '靠右显示'], 'dependency' => ['image|free_size', '!=|!=', '|']], + ['id' => 'parallax', 'type' => 'spinner', 'label' => '视差滚动', 'default' => 0, 'min' => -200, 'max' => 200, 'step' => 5, 'dependency' => ['image', '!=', '']], + ['id' => 'parallax_scale', 'type' => 'spinner', 'label' => '视差缩放', 'default' => 100, 'min' => 1, 'max' => 200, 'step' => 5, 'dependency' => ['image', '!=', '']], + ['id' => 'parallax_opacity', 'type' => 'spinner', 'label' => '视差透明', 'default' => 100, 'min' => 1, 'max' => 100, 'step' => 5, 'dependency' => ['image|parallax', '!=|!=', '|']], + ]], + ['id' => 'text', 'type' => 'accordion', 'label' => '叠加文案', 'fields' => [ + ['id' => 'title', 'type' => 'textarea', 'label' => '幻灯片文案', 'default' => '', 'allow_html' => true], + ['id' => 'desc', 'type' => 'textarea', 'label' => '幻灯片简介', 'default' => '', 'allow_html' => true, 'dependency' => ['title', '!=', '']], + ['id' => 'text_align', 'type' => 'image_select', 'label' => '显示位置', 'default' => 'left-bottom', 'options' => ['left-bottom' => $align_uri . 'left-bottom.jpg', 'left-conter' => $align_uri . 'left-conter.jpg', 'conter-conter' => $align_uri . 'conter-conter.jpg', 'conter-bottom' => $align_uri . 'conter-bottom.jpg', 'right-conter' => $align_uri . 'right-conter.jpg', 'right-bottom' => $align_uri . 'right-bottom.jpg']], + ['id' => 'text_size_pc', 'type' => 'spinner', 'label' => 'PC端字体大小', 'default' => 30, 'min' => 12, 'max' => 50], + ['id' => 'text_size_m', 'type' => 'spinner', 'label' => '移动端字体大小', 'default' => 20, 'min' => 12, 'max' => 50], + ['id' => 'parallax', 'type' => 'spinner', 'label' => '文案视差滚动', 'default' => 40, 'min' => -200, 'max' => 200, 'step' => 10], + ]], + ]; $fields = [ - ['id' => 'direction', 'type' => 'radio', 'label' => '方向', 'default' => 'horizontal', 'options' => ['horizontal' => '横向', 'vertical' => '纵向']], - ['id' => 'effect', 'type' => 'select', 'label' => '切换效果', 'default' => 'slide', 'options' => ['slide' => '滑动', 'fade' => '淡入', 'cube' => '立方体', 'coverflow' => '封面流']], - ['id' => 'pc_height', 'type' => 'spinner', 'label' => 'PC 高度', 'default' => 400, 'min' => 120, 'max' => 1000], - ['id' => 'm_height', 'type' => 'spinner', 'label' => '移动端高度', 'default' => 240, 'min' => 100, 'max' => 800], - ['id' => 'spacebetween', 'type' => 'spinner', 'label' => '间距', 'default' => 15, 'min' => 0, 'max' => 100], - ['id' => 'speed', 'type' => 'spinner', 'label' => '切换速度(毫秒)', 'default' => 500, 'min' => 100, 'max' => 5000], - ['id' => 'interval', 'type' => 'spinner', 'label' => '自动播放间隔(秒)', 'default' => 4, 'min' => 1, 'max' => 60], - ['id' => 'autoplay', 'type' => 'switcher', 'label' => '自动播放', 'default' => true], - ['id' => 'loop', 'type' => 'switcher', 'label' => '循环', 'default' => true], - ['id' => 'button', 'type' => 'switcher', 'label' => '显示切换按钮', 'default' => true], - ['id' => 'pagination', 'type' => 'switcher', 'label' => '显示分页器', 'default' => true], - ['id' => 'slides', 'type' => 'group', 'label' => '幻灯片', 'max_items' => 30, 'fields' => [ - ['id' => 'background', 'type' => 'upload', 'label' => '背景图片', 'default' => ''], - ['id' => 'background_video', 'type' => 'upload', 'label' => '背景视频', 'default' => ''], - ['id' => 'hide_mobile', 'type' => 'switcher', 'label' => '移动端隐藏', 'default' => false], - ['id' => 'link', 'type' => 'link', 'label' => '链接', 'default' => []], - ['id' => 'title', 'type' => 'text', 'label' => '标题', 'default' => ''], - ['id' => 'description', 'type' => 'textarea', 'label' => '说明', 'default' => ''], - ['id' => 'text_align', 'type' => 'select', 'label' => '文字位置', 'default' => 'left-bottom', 'options' => ['left-top' => '左上', 'left-center' => '左中', 'left-bottom' => '左下', 'center' => '居中', 'right-bottom' => '右下']], - ['id' => 'layers', 'type' => 'group', 'label' => '图片图层', 'max_items' => 12, 'fields' => [['id' => 'image', 'type' => 'upload', 'label' => '图片', 'default' => ''], ['id' => 'parallax', 'type' => 'spinner', 'label' => '视差', 'default' => 0, 'min' => -300, 'max' => 300], ['id' => 'scale', 'type' => 'slider', 'label' => '缩放', 'default' => 100, 'min' => 10, 'max' => 300]]], + ['id' => 'slides', 'type' => 'group', 'label' => '幻灯片内容', 'max_items' => 30, 'min_items' => 1, 'default' => [['background' => $asset_uri . 'slider-bg.jpg', 'image_layer' => [['image' => $asset_uri . 'slider-layer-1.png', 'align' => 'center', 'free_size' => true, 'parallax' => -100, 'parallax_scale' => 100, 'parallax_opacity' => 100], ['image' => $asset_uri . 'slider-layer-2.png', 'align' => 'center', 'free_size' => true, 'parallax' => -50, 'parallax_scale' => 50, 'parallax_opacity' => 30]]]], 'fields' => $slide_fields], + ['id' => 'option', 'type' => 'accordion', 'label' => '幻灯片设置', 'default' => ['direction' => 'horizontal', 'button' => true, 'pagination' => true, 'effect' => 'slide', 'auto_height' => false, 'pc_height' => 400, 'm_height' => 240, 'spacebetween' => 15, 'speed' => 0, 'interval' => 4], 'fields' => [ + ['id' => 'direction', 'type' => 'radio', 'label' => '幻灯片方向', 'default' => 'horizontal', 'options' => ['horizontal' => '左右切换', 'vertical' => '上下切换']], + ['id' => 'loop', 'type' => 'switcher', 'label' => '循环切换', 'default' => true], + ['id' => 'button', 'type' => 'switcher', 'label' => '显示翻页按钮', 'default' => false], + ['id' => 'pagination', 'type' => 'switcher', 'label' => '显示指示器', 'default' => false], + ['id' => 'effect', 'type' => 'select', 'label' => '切换动画', 'default' => 'slide', 'options' => ['slide' => '滑动', 'fade' => '淡出淡入', 'cube' => '3D方块', 'coverflow' => '3D滑入', 'flip' => '3D翻转']], + ['id' => 'scale_height', 'type' => 'switcher', 'label' => '按比例自动高度', 'default' => true, 'dependency' => ['direction', '!=', 'vertical']], + ['id' => 'scale', 'type' => 'spinner', 'label' => '长宽比例', 'default' => 35, 'min' => 10, 'max' => 300, 'step' => 5, 'dependency' => ['direction|scale_height', '!=|!=', 'vertical|']], + ['id' => 'auto_height', 'type' => 'switcher', 'label' => '自动高度', 'default' => false, 'dependency' => ['direction|scale_height', '!=|==', 'vertical|']], + ['id' => 'max_height', 'type' => 'spinner', 'label' => '最大高度', 'default' => 500, 'min' => 120, 'max' => 800, 'step' => 20, 'dependency' => ['auto_height', '!=', '']], + ['id' => 'min_height', 'type' => 'spinner', 'label' => '最小高度', 'default' => 180, 'min' => 100, 'max' => 500, 'step' => 20, 'dependency' => ['auto_height', '!=', '']], + ['id' => 'pc_height', 'type' => 'spinner', 'label' => '电脑端高度', 'default' => 400, 'min' => 120, 'max' => 800, 'step' => 20], + ['id' => 'm_height', 'type' => 'spinner', 'label' => '移动端高度', 'default' => 200, 'min' => 100, 'max' => 500, 'step' => 20], + ['id' => 'spacebetween', 'type' => 'spinner', 'label' => '幻灯片间距', 'default' => 15, 'min' => 0, 'max' => 500, 'step' => 5], + ['id' => 'speed', 'type' => 'spinner', 'label' => '切换速度', 'default' => 0, 'min' => 0, 'max' => 3000, 'step' => 100], + ['id' => 'autoplay', 'type' => 'switcher', 'label' => '自动播放', 'default' => true], + ['id' => 'interval', 'type' => 'spinner', 'label' => '停顿时间', 'default' => 4, 'min' => 0, 'max' => 20, 'dependency' => ['autoplay', '!=', '']], ]], ]; if ($slug === 'slider_media_combo') { - $fields[] = ['id' => 'side_cards', 'type' => 'group', 'label' => '右侧图文视频卡片', 'max_items' => 6, 'fields' => self::media_card_fields()]; + $combo_slides = [ + ['background' => $asset_uri . 'slider-bg.jpg', 'text' => ['title' => '更优雅的 WordPress 主题:AKSRT', 'desc' => '', 'text_align' => 'left-bottom', 'text_size_m' => 16, 'text_size_pc' => 24, 'parallax' => 0]], + ['background' => $asset_uri . 'slider-bg.jpg', 'text' => ['title' => '更优雅的 WordPress 主题 AKSRT', 'desc' => '', 'text_align' => 'left-bottom', 'text_size_m' => 16, 'text_size_pc' => 24, 'parallax' => 0]], + ]; + $combo_option = [ + ['id' => 'loop', 'type' => 'switcher', 'label' => '循环切换', 'default' => true], + ['id' => 'button', 'type' => 'switcher', 'label' => '显示翻页按钮', 'default' => true], + ['id' => 'pagination', 'type' => 'switcher', 'label' => '显示指示器', 'default' => true], + ['id' => 'effect', 'type' => 'select', 'label' => '切换动画', 'default' => 'slide', 'options' => ['slide' => '滑动', 'fade' => '淡出淡入', 'cube' => '3D方块', 'coverflow' => '3D滑入', 'flip' => '3D翻转']], + ['id' => 'spacebetween', 'type' => 'spinner', 'label' => '幻灯片间距', 'default' => 20, 'min' => 0, 'max' => 500, 'step' => 5], + ['id' => 'speed', 'type' => 'spinner', 'label' => '切换速度', 'default' => 0, 'min' => 0, 'max' => 3000, 'step' => 100], + ['id' => 'autoplay', 'type' => 'switcher', 'label' => '自动播放', 'default' => true], + ['id' => 'interval', 'type' => 'spinner', 'label' => '停顿时间', 'default' => 4, 'min' => 0, 'max' => 20, 'dependency' => ['autoplay', '!=', '']], + ]; + $fields = [ + ['id' => 'layout_ratio', 'type' => 'slider', 'label' => '幻灯片宽度占比', 'default' => 60, 'min' => 0, 'max' => 100], + ['id' => 'slide_scale', 'type' => 'spinner', 'label' => '幻灯片长宽比', 'default' => 50, 'min' => 0, 'max' => 500, 'step' => 5], + ['id' => 'pc_row', 'type' => 'button_set', 'label' => '右侧卡片列数', 'default' => 2, 'options' => [1 => '1列', 2 => '2列', 3 => '3列', 4 => '4列']], + ['id' => 'pc_col', 'type' => 'button_set', 'label' => '右侧卡片行数', 'default' => 2, 'options' => [1 => '1行', 2 => '2行', 3 => '3行', 4 => '4行']], + ['id' => 'm_layout_type', 'type' => 'radio', 'label' => '移动端布局方式', 'default' => 'vertical', 'options' => ['default' => '保持PC端布局', 'vertical' => '上下布局', 'hide_slide' => '隐藏幻灯片', 'hide_cover' => '隐藏卡片']], + ['id' => 'm_row', 'type' => 'button_set', 'label' => '移动端卡片单行数量', 'default' => 2, 'dependency' => ['m_layout_type', 'any', 'hide_slide,vertical'], 'options' => [1 => '1个', 2 => '2个', 3 => '3个', 4 => '4个', 6 => '6个']], + ['id' => 'm_cover_ratio', 'type' => 'spinner', 'label' => '移动端卡片长宽比', 'default' => 45, 'min' => 5, 'max' => 300, 'step' => 5, 'dependency' => ['m_layout_type', 'any', 'hide_slide,vertical']], + ['id' => 'slides', 'type' => 'group', 'label' => '幻灯片内容', 'max_items' => 30, 'min_items' => 1, 'default' => $combo_slides, 'fields' => $slide_fields], + ['id' => 'option', 'type' => 'accordion', 'label' => '幻灯片设置', 'default' => ['button' => true, 'pagination' => true, 'effect' => 'slide', 'spacebetween' => 20, 'speed' => 0, 'interval' => 4, 'autoplay' => true, 'loop' => true], 'fields' => $combo_option], + ['id' => 'covers', 'type' => 'group', 'label' => '右侧图文视频卡片', 'max_items' => 30, 'default' => array_fill(0, 4, ['image' => $asset_uri . 'user_t.jpg', 'title' => '']), 'fields' => [ + ['id' => 'image', 'type' => 'upload', 'label' => '图片背景', 'default' => '', 'library' => 'image'], ['id' => 'video', 'type' => 'upload', 'label' => '视频背景', 'default' => '', 'library' => 'video'], ['id' => 'hide', 'type' => 'radio', 'label' => '显示规则', 'default' => '', 'options' => ['' => '全部显示', 'pc' => 'PC端不显示', 'm' => '移动端不显示']], ['id' => 'title', 'type' => 'textarea', 'label' => '文字', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1]], ['id' => 'link', 'type' => 'link', 'label' => '跳转链接', 'default' => []], + ]], + ['id' => 'mask_opacity', 'type' => 'slider', 'label' => '遮罩透明度', 'default' => 0, 'min' => 0, 'max' => 90], ['id' => 'font_size_pc', 'type' => 'spinner', 'label' => 'PC端字体大小', 'default' => 16, 'min' => 10, 'max' => 80, 'step' => 2], ['id' => 'font_size_m', 'type' => 'spinner', 'label' => '移动端字体大小', 'default' => 14, 'min' => 10, 'max' => 80, 'step' => 2], ['id' => 'font_bold', 'type' => 'switcher', 'label' => '粗体显示', 'default' => false], ['id' => 'font_color', 'type' => 'color', 'label' => '文字颜色', 'default' => ''], + ]; } return $fields; } diff --git a/tests/widget-system/zibll-modern-widgets-contract.test.cjs b/tests/widget-system/zibll-modern-widgets-contract.test.cjs new file mode 100644 index 00000000..647672a2 --- /dev/null +++ b/tests/widget-system/zibll-modern-widgets-contract.test.cjs @@ -0,0 +1,36 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('modern widget schemas expose Zibll field names and defaults', () => { + const source = read('src/WidgetSystem/WidgetSchemas.php'); + for (const field of ['mini_opt', 'paginate', 'tabs', "'id' => 'cat'", "'id' => 'topics'", 'zibpay_type', 'layout_ratio', 'slide_scale', 'm_layout_type', 'm_cover_ratio', 'image_layer', 'image_select']) { + assert.match(source, new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + assert.match(source, /'热门推荐'.*'orderby' => 'views'/s); + assert.match(source, /'最近更新'.*'orderby' => 'modified'/s); + assert.match(source, /'猜你喜欢'.*'orderby' => 'rand'/s); +}); + +test('tab and slider renderers retain reference DOM contracts', () => { + const content = read('src/WidgetSystem/Renderers/ContentRenderer.php'); + const slider = read('src/WidgetSystem/Renderers/SliderRenderer.php'); + for (const token of ['widget-tab-post', 'posts-mini-lists', 'index-tab', 'tab-nav-theme', 'tab-pane fade', 'post_ajax_trigger', 'post_ajax_loader', 'widget-ajaxpager']) assert.match(content, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + for (const token of ['relative zib-slider', 'new-swiper slide-widget', 'swiper-wrapper', 'swiper-slide', 'slide-layer', 'data-swiper-parallax', 'swiper-button-prev', 'swiper-pagination', 'widget-slider-graphic', 'slider-col', 'cover-col', 'widget-graphic-cover']) assert.match(slider, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +}); + +test('reference slider CSS includes responsive height and combo selectors', () => { + const css = read('assets/widgets/frontend.css'); + for (const token of ['.new-swiper.slide-widget.scale-height', '--scale-height', '--pc-height', '--m-height', '.widget-slider-graphic', '.m-hide_slide', '.m-hide_cover', '--m-cover-ratio']) assert.match(css, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +}); + +test('admin core supports composite dependencies and nested repeater renumbering', () => { + const core = require(path.join(root, 'assets/widgets/admin-core.js')); + assert.equal(core.dependencyVisible([['direction', '!=', 'vertical'], ['scale_height', '!=', '']], { direction: 'horizontal', scale_height: '1' }), true); + assert.equal(core.dependencyVisible(['image|free_size', '!=|!=', '|'], { image: 'x', free_size: '1' }), true); + assert.equal(core.replaceRepeaterIndexes('widget[3][0][1]', [4, 2]), 'widget[3][4][2]'); +});