From ad99ab53ce10e3aea1a4d22892e8a286dbb348b2 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 17 Aug 2026 17:29:54 +0800 Subject: [PATCH] feat: rewrite schema-driven widget system --- .specs/baselines/widget-reference.json | 35 ++ .specs/plans/aksrt-widgets-rewrite.md | 40 +++ assets/widgets/admin.css | 166 ++++++++++ assets/widgets/admin.js | 129 ++++++++ assets/widgets/frontend.css | 135 ++++++++ assets/widgets/frontend.js | 290 ++++++++++++++++ src/WidgetSystem/AdminFormController.php | 64 ++++ src/WidgetSystem/AssetManager.php | 72 ++++ src/WidgetSystem/CommonFields.php | 91 ++++++ src/WidgetSystem/FieldSchema.php | 195 +++++++++++ src/WidgetSystem/FormRenderer.php | 232 +++++++++++++ src/WidgetSystem/FrontendRenderer.php | 110 +++++++ src/WidgetSystem/PostQuery.php | 94 ++++++ src/WidgetSystem/PostRenderer.php | 95 ++++++ src/WidgetSystem/RendererHelpers.php | 67 ++++ src/WidgetSystem/RendererRegistry.php | 54 +++ .../Renderers/ContentRenderer.php | 136 ++++++++ .../Renderers/GeneralRenderer.php | 196 +++++++++++ .../Renderers/KnowledgeProductRenderer.php | 119 +++++++ src/WidgetSystem/Renderers/SliderRenderer.php | 55 ++++ src/WidgetSystem/Renderers/UserRenderer.php | 164 ++++++++++ src/WidgetSystem/RestController.php | 106 ++++++ src/WidgetSystem/SchemaWidget.php | 73 +++++ src/WidgetSystem/WidgetCatalog.php | 97 ++++++ src/WidgetSystem/WidgetDefinition.php | 98 ++++++ src/WidgetSystem/WidgetRegistry.php | 49 +++ src/WidgetSystem/WidgetSchemas.php | 309 ++++++++++++++++++ tools/audit-widget-system.php | 218 ++++++++++++ 28 files changed, 3489 insertions(+) create mode 100644 .specs/baselines/widget-reference.json create mode 100644 .specs/plans/aksrt-widgets-rewrite.md create mode 100644 assets/widgets/admin.css create mode 100644 assets/widgets/admin.js create mode 100644 assets/widgets/frontend.css create mode 100644 assets/widgets/frontend.js create mode 100644 src/WidgetSystem/AdminFormController.php create mode 100644 src/WidgetSystem/AssetManager.php create mode 100644 src/WidgetSystem/CommonFields.php create mode 100644 src/WidgetSystem/FieldSchema.php create mode 100644 src/WidgetSystem/FormRenderer.php create mode 100644 src/WidgetSystem/FrontendRenderer.php create mode 100644 src/WidgetSystem/PostQuery.php create mode 100644 src/WidgetSystem/PostRenderer.php create mode 100644 src/WidgetSystem/RendererHelpers.php create mode 100644 src/WidgetSystem/RendererRegistry.php create mode 100644 src/WidgetSystem/Renderers/ContentRenderer.php create mode 100644 src/WidgetSystem/Renderers/GeneralRenderer.php create mode 100644 src/WidgetSystem/Renderers/KnowledgeProductRenderer.php create mode 100644 src/WidgetSystem/Renderers/SliderRenderer.php create mode 100644 src/WidgetSystem/Renderers/UserRenderer.php create mode 100644 src/WidgetSystem/RestController.php create mode 100644 src/WidgetSystem/SchemaWidget.php create mode 100644 src/WidgetSystem/WidgetCatalog.php create mode 100644 src/WidgetSystem/WidgetDefinition.php create mode 100644 src/WidgetSystem/WidgetRegistry.php create mode 100644 src/WidgetSystem/WidgetSchemas.php create mode 100644 tools/audit-widget-system.php diff --git a/.specs/baselines/widget-reference.json b/.specs/baselines/widget-reference.json new file mode 100644 index 00000000..03dc773d --- /dev/null +++ b/.specs/baselines/widget-reference.json @@ -0,0 +1,35 @@ +{ + "schema": "aksrt.widget-reference.v1", + "captured_at": "2026-08-17", + "source_root": "C:\\Users\\Xing\\Desktop\\zibll", + "source_policy": { + "modern_widget_count": 37, + "include_core_product_marketing": true, + "exclude_bbs_branch": true, + "exclude_shop_branch": true, + "exclude_deprecated_widgets": true, + "branding": "AKSRT" + }, + "files": [ + {"path":"inc/widgets/widget-class.php","bytes":2123,"sha256":"92e8ae46c47cc6d9f46312113e182105b013c8073c29da61ef048680c9cc814b"}, + {"path":"inc/widgets/widget-import.php","bytes":146458,"sha256":"dca6b442e35f1804a7fe9130e7df9ac4c11772c85774c1faa69ebacb48df9b62"}, + {"path":"inc/widgets/widget-index.php","bytes":18826,"sha256":"6f15b1b73f909ea7eccf4697b5274a38512deae2e931ae7b0a33e48a5fcd8b25"}, + {"path":"inc/widgets/widget-more.php","bytes":167708,"sha256":"07ea0c3260a9ee9701b1ce03795941fbbfb61c377ad71fe8f7820107763c5528"}, + {"path":"inc/widgets/widget-posts.php","bytes":99676,"sha256":"20b04007270ecfd265adaaa287eb1a696566385b1f86379079be9664cfaea409"}, + {"path":"inc/widgets/widget-produck.php","bytes":121409,"sha256":"77194592685b50e5428c6db4d0e2ca2cbc52d0ff4a81200f4e10b56e26159b9d"}, + {"path":"inc/widgets/widget-slider.php","bytes":45007,"sha256":"a06a12500cb8ffbf57e496d9979eaed0edd545499ecb9c80d011d3ae49d2244e"}, + {"path":"inc/widgets/widget-user.php","bytes":24655,"sha256":"97a4fde11d0a93af229e499f9b11c8d769b21b6b7500eac9bb94b4dd850f6b24"}, + {"path":"inc/csf-framework/classes/widget-options.class.php","bytes":40070,"sha256":"ac7af19cc5d981eff933417430f45eb5bb0b1ff7dc8a0462238b79c9bab74722"}, + {"path":"css/widget-set.css","bytes":5567,"sha256":"a11605b3e30d2ee372438c4242929461cbcc369fed1fa417a8046b4f15ae8a9f"}, + {"path":"js/widget-set.js","bytes":35437,"sha256":"bdb9ac07039ec85f1ac90a92ca4496f0c625b7bdeea6a468bd9a30814e968d8f"} + ], + "groups": { + "general": ["search","posts_toc","quote","icon_cards","video","embed","media_cards","icon_cover_cards","partners","combo_info","text_title","buttons","layout_gap","tag_cloud","recent_comments","links","notice"], + "content": ["term_cards","term_aggregation","hot_posts","post_pay","posts","tab_posts","oneline_posts"], + "knowledge_product": ["pricing_plans","reviews","faq","stats","highlights","feature_intro","tilt_cases"], + "slider": ["slider","slider_media_combo"], + "user": ["user_card","author_card","user_ranking","user_list"] + }, + "excluded_legacy": ["widget_ui_main_tab_posts","widget_ui_mini_tab_posts","widget_ui_mini_posts","widget_ui_mian_posts","widget_ui_slider"], + "excluded_paths": ["inc/functions/bbs/widgets","inc/functions/shop/widgets"] +} diff --git a/.specs/plans/aksrt-widgets-rewrite.md b/.specs/plans/aksrt-widgets-rewrite.md new file mode 100644 index 00000000..d2597196 --- /dev/null +++ b/.specs/plans/aksrt-widgets-rewrite.md @@ -0,0 +1,40 @@ +# Plan: AKSRT 小工具系统全量重写 + +> 用户确认基线:`C:\Users\Xing\Desktop\zibll` +> 目标:37 个现代核心小工具;排除论坛、商城和废弃旧版;无兼容、迁移或双路运行。 + +## Architectural Decisions + +- 使用全新的 `AKSRT\WidgetSystem` 数据驱动内核,不引入 Codestar/CSF。 +- 新实例 ID 统一为 `aksrt_widget_`,不注册或读取现有小工具 ID。 +- 开发期新系统在未接入 bootstrap 前保持不可达;最终以单次注册器切换删除旧运行路径。 +- 参考“专题”映射为 `category`/`post_tag`;付费类型只使用 AKSRT `DomainModel`。 +- 最终树只保留一套小工具 CSS、JS、REST 与挂载区域实现。 + +## Phase 1 — 参考基线与原生内核 + +- [x] 冻结参考文件哈希、37 项现代组件清单和明确排除项。 +- [x] 实现 `WidgetDefinition`、`WidgetRegistry`、字段 schema、通用字段与递归规范化。 +- [x] 实现后台表单渲染、懒加载控制器及独立 admin CSS/JS 基础。 +- [x] 增加注册表/字段契约审计并通过 PHP 语法与审计检查。 + +## Phase 2 — 37 个渲染器与动态数据接口 + +- [x] 实现 17 个通用/装饰组件。 +- [x] 实现 7 个文章/知识付费组件和统一查询、卡片、分页链路。 +- [x] 实现 7 个知识产品、2 个幻灯片和 4 个用户组件。 +- [x] 实现统一 REST items 路由、一言路由及按需前端资源。 + +## Phase 3 — 原子切换、挂载区域与导入导出 + +- [ ] 替换注册器并删除旧小工具类、旧公共层、旧路由和重复资源。 +- [ ] 重写 40 个静态挂载区域、动态页面区域及所有模板消费点。 +- [ ] 实现 `aksrt.widgets.v1` 导入导出和 5 套知识付费模板。 +- [ ] 核对默认 WordPress 小工具注销策略与区块编辑器/自定义器行为。 + +## Phase 4 — 自动化与视觉终验 + +- [ ] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 +- [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。 +- [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。 +- [ ] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 diff --git a/assets/widgets/admin.css b/assets/widgets/admin.css new file mode 100644 index 00000000..1326c64f --- /dev/null +++ b/assets/widgets/admin.css @@ -0,0 +1,166 @@ +.aksrt-widget-form { + --aksrt-field-border: #dcdcde; + --aksrt-field-accent: #2271b1; + display: grid; + gap: 14px; + padding: 4px 0 10px; +} + +.aksrt-widget-field { + min-width: 0; +} + +.aksrt-widget-field[hidden] { + display: none !important; +} + +.aksrt-widget-field__label { + display: block; + margin-bottom: 6px; + font-weight: 600; +} + +.aksrt-widget-field .description { + margin: -2px 0 7px; +} + +.aksrt-widget-switch { + display: inline-flex; + cursor: pointer; +} + +.aksrt-widget-switch input { + position: absolute; + opacity: 0; +} + +.aksrt-widget-switch span { + position: relative; + width: 38px; + height: 22px; + border-radius: 999px; + background: #8c8f94; + transition: background .18s ease; +} + +.aksrt-widget-switch span::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform .18s ease; +} + +.aksrt-widget-switch input:checked + span { background: var(--aksrt-field-accent); } +.aksrt-widget-switch input:checked + span::after { transform: translateX(16px); } +.aksrt-widget-switch input:focus-visible + span { outline: 2px solid #72aee6; outline-offset: 2px; } + +.aksrt-widget-choices { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.aksrt-widget-choices label input { + position: absolute; + opacity: 0; +} + +.aksrt-widget-choices label span { + display: block; + padding: 5px 9px; + border: 1px solid var(--aksrt-field-border); + border-radius: 5px; + background: #fff; + cursor: pointer; +} + +.aksrt-widget-choices label input:checked + span { + border-color: var(--aksrt-field-accent); + color: var(--aksrt-field-accent); + box-shadow: 0 0 0 1px var(--aksrt-field-accent); +} + +.aksrt-widget-number, +.aksrt-widget-icon, +.aksrt-widget-link, +.aksrt-widget-background { + display: grid; + gap: 7px; +} + +.aksrt-widget-number { + grid-template-columns: minmax(0, 1fr) 50px; + align-items: center; +} + +.aksrt-widget-upload { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; +} + +.aksrt-widget-upload img { + width: 100%; + max-height: 150px; + border-radius: 6px; + object-fit: cover; +} + +.aksrt-widget-upload .widefat { flex: 1 1 100%; } + +.aksrt-widget-spacing { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.aksrt-widget-spacing label span { + display: block; + margin-bottom: 3px; + color: #646970; + text-align: center; +} + +.aksrt-widget-repeater__row, +.aksrt-widget-nested { + margin-bottom: 9px; + border: 1px solid var(--aksrt-field-border); + border-radius: 6px; + background: #fff; +} + +.aksrt-widget-repeater__bar { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border-bottom: 1px solid var(--aksrt-field-border); + background: #f6f7f7; +} + +.aksrt-widget-repeater__bar .dashicons { cursor: move; } +.aksrt-widget-repeater__bar strong { flex: 1; } +.aksrt-widget-repeater__fields, +.aksrt-widget-nested__body { display: grid; gap: 12px; padding: 10px; } +.aksrt-widget-nested > summary { padding: 9px 10px; font-weight: 600; cursor: pointer; } + +.aksrt-widget-form-lazy { + padding: 14px; + border: 1px dashed #c3c4c7; + border-radius: 6px; + text-align: center; +} + +.aksrt-widget-form-lazy__status { margin-top: 0; color: #646970; } + +@media (prefers-reduced-motion: reduce) { + .aksrt-widget-switch span, + .aksrt-widget-switch span::after { transition: none; } +} + diff --git a/assets/widgets/admin.js b/assets/widgets/admin.js new file mode 100644 index 00000000..7e60bc3d --- /dev/null +++ b/assets/widgets/admin.js @@ -0,0 +1,129 @@ +(function ($) { + 'use strict'; + + const config = window.aksrtWidgetAdmin || {}; + + function initForm(root) { + const $root = $(root); + $root.find('.aksrt-widget-color').wpColorPicker(); + $root.find('.aksrt-widget-repeater__rows').sortable({ + handle: '.dashicons-move', + axis: 'y', + update: renumberRepeaters, + }); + $root.find('input[type="range"]').each(function () { + const output = this.parentElement.querySelector('output'); + if (output) this.addEventListener('input', () => { output.textContent = this.value; }); + }); + evaluateDependencies($root); + } + + function loadForm(container) { + const $container = $(container); + if ($container.data('loading') || $container.data('loaded')) return; + $container.data('loading', true).find('.aksrt-widget-form-lazy__status').text(config.i18n?.loading || 'Loading…'); + $.post(config.ajaxUrl, { + action: config.action, + nonce: config.nonce, + id_base: $container.data('id-base'), + widget_number: $container.data('widget-number'), + }).done((response) => { + if (!response?.success || !response.data?.html) throw new Error('Invalid response'); + const $form = $(response.data.html); + $container.replaceWith($form); + initForm($form); + }).fail(() => { + $container.data('loading', false).find('.aksrt-widget-form-lazy__status').text(config.i18n?.error || 'Failed to load.'); + }); + } + + function renumberRepeaters(_event, ui) { + const $form = $(ui?.item || this).closest('.aksrt-widget-form'); + $form.find('.aksrt-widget-repeater__rows').each(function () { + $(this).children('.aksrt-widget-repeater__row').each(function (index) { + this.dataset.rowIndex = String(index); + }); + }); + $form.find('[name]').each(function () { + const rowIndexes = $(this).parents('.aksrt-widget-repeater__row').get().reverse().map((row) => Number(row.dataset.rowIndex || 0)); + if (!rowIndexes.length) return; + let foundWidgetNumber = false; + let cursor = 0; + this.name = this.name.replace(/\[(\d+|__index_[a-f0-9]+__)\]/g, (match, index) => { + if (!foundWidgetNumber && /^\d+$/.test(index)) { + foundWidgetNumber = true; + return match; + } + return cursor < rowIndexes.length ? `[${rowIndexes[cursor++]}]` : match; + }); + }); + } + + function fieldValue($form, id) { + const $field = $form.find(`.aksrt-widget-field[data-field-id="${CSS.escape(id)}"]`).first(); + const $checked = $field.find('input:checked').first(); + if ($checked.length) return String($checked.val()); + return String($field.find('input,select,textarea').first().val() ?? ''); + } + + function evaluateDependencies($form) { + $form.find('[data-dependency]').each(function () { + let rule; + try { rule = JSON.parse(this.dataset.dependency || '[]'); } catch (_error) { return; } + if (!Array.isArray(rule) || rule.length < 3) return; + const $row = $(this).closest('.aksrt-widget-repeater__row'); + const current = fieldValue($row.length ? $row : $form, String(rule[0])); + const expected = String(rule[2]); + const visible = rule[1] === '==' ? current === expected : current !== expected; + this.hidden = !visible; + }); + } + + $(document) + .on('click', '.aksrt-widget-form-lazy__load', function () { loadForm($(this).closest('.aksrt-widget-form-lazy')); }) + .on('click', '.widget.open .aksrt-widget-form-lazy', function () { loadForm(this); }) + .on('change input', '.aksrt-widget-form input, .aksrt-widget-form select, .aksrt-widget-form textarea', function () { evaluateDependencies($(this).closest('.aksrt-widget-form')); }) + .on('click', '.aksrt-widget-repeater__add', function () { + const $repeater = $(this).closest('.aksrt-widget-repeater'); + const max = Number($repeater.data('max-items') || 50); + const $rows = $repeater.children('.aksrt-widget-repeater__rows'); + if ($rows.children().length >= max) return; + const index = $rows.children().length; + const token = String($repeater.data('index-token') || '__index__'); + const html = $repeater.children('template').html().replaceAll(token, String(index)); + const $row = $(html).appendTo($rows); + initForm($row); + renumberRepeaters.call($rows[0]); + }) + .on('click', '.aksrt-widget-repeater__remove', function () { + const $rows = $(this).closest('.aksrt-widget-repeater__rows'); + $(this).closest('.aksrt-widget-repeater__row').remove(); + renumberRepeaters.call($rows[0]); + }) + .on('click', '.aksrt-widget-upload__choose', function () { + const $wrap = $(this).closest('.aksrt-widget-upload'); + const frame = wp.media({ title: '选择媒体', multiple: false }); + frame.on('select', () => { + const attachment = frame.state().get('selection').first().toJSON(); + $wrap.find('input[type="url"]').val(attachment.url).trigger('change'); + let $image = $wrap.find('img'); + if (!$image.length) $image = $('').prependTo($wrap); + $image.attr('src', attachment.url); + }); + frame.open(); + }) + .on('click', '.aksrt-widget-upload__remove', function () { + const $wrap = $(this).closest('.aksrt-widget-upload'); + $wrap.find('input[type="url"]').val('').trigger('change'); + $wrap.find('img').remove(); + }); + + $(document).on('widget-added widget-updated', function (_event, widget) { + $(widget).find('.aksrt-widget-form-lazy').each(function () { loadForm(this); }); + }); + + $(function () { + $('.aksrt-widget-form').each(function () { initForm(this); }); + $('.widget.open .aksrt-widget-form-lazy').each(function () { loadForm(this); }); + }); +})(jQuery); diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css new file mode 100644 index 00000000..62e24ff9 --- /dev/null +++ b/assets/widgets/frontend.css @@ -0,0 +1,135 @@ +.aksrt-widget { + --aksrt-widget-bg-light: transparent; + --aksrt-widget-bg-dark: transparent; + --aksrt-widget-radius: 12px; + margin: var(--aksrt-widget-space-top, 0) var(--aksrt-widget-space-right, 0) var(--aksrt-widget-space-bottom, 20px) var(--aksrt-widget-space-left, 0); + background-color: var(--aksrt-widget-bg-light); + background-image: var(--aksrt-widget-bg-image-light, none); + background-position: center; + background-size: cover; + color: var(--theme-color, #2d2d35); +} +.dark-theme .aksrt-widget, +[data-theme="dark"] .aksrt-widget { background-color: var(--aksrt-widget-bg-dark); background-image: var(--aksrt-widget-bg-image-dark, none); color: #e7e7ea; } +.aksrt-widget *, .aksrt-widget *::before, .aksrt-widget *::after { box-sizing: border-box; } +.aksrt-widget img, .aksrt-widget video { max-width: 100%; } +.aksrt-widget a { color: inherit; text-decoration: none; } +.aksrt-widget button, .aksrt-widget input { font: inherit; } +.aksrt-widget__header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 16px; } +.aksrt-widget__header--big { display: block; text-align: center; margin-bottom: 30px; } +.aksrt-widget__title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 700; } +.aksrt-widget__header--big .aksrt-widget__title { font-size: clamp(27px, 4vw, 42px); } +.aksrt-widget__title mark { padding: 0; background: transparent; color: var(--focus-color, #ff5473); } +.aksrt-widget__subtitle { margin: 7px 0 0; color: var(--muted-color, #8a8c98); } +.aksrt-widget__title-links { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.aksrt-widget__title-links a { padding: 5px 11px; border-radius: 999px; background: #f2f3f5; font-size: 13px; } +.dark-theme .aksrt-widget__title-links a, [data-theme="dark"] .aksrt-widget__title-links a { background: #292a30; } +.aksrt-widget-grid { display: grid; grid-template-columns: repeat(var(--aksrt-cols, 3), minmax(0, 1fr)); gap: 16px; } +.aksrt-widget__empty, .aksrt-widget-request-error { padding: 28px 18px; border: 1px dashed rgba(127,127,127,.25); border-radius: var(--aksrt-widget-radius); color: var(--muted-color, #8a8c98); text-align: center; } +.aksrt-widget-request-error button { margin-left: 10px; padding: 5px 12px; border: 0; border-radius: 999px; cursor: pointer; } +.aksrt-widget--affix { position: sticky; top: 76px; } +.aksrt-widget--only-mobile { display: none; } + +.aksrt-widget-search { display: flex; min-height: 46px; padding: 5px; border: 1px solid rgba(127,127,127,.18); border-radius: 10px; background: var(--main-bg, #fff); box-shadow: 0 8px 25px rgba(31,35,48,.06); } +.aksrt-widget-search label { flex: 1; display: flex; } +.aksrt-widget-search input { width: 100%; padding: 8px 12px; border: 0; outline: 0; background: transparent; color: inherit; } +.aksrt-widget-search button { display: flex; align-items: center; gap: 7px; padding: 8px 16px; border: 0; border-radius: 8px; background: var(--focus-color, #ff5473); color: #fff; cursor: pointer; } +.aksrt-widget-search--compact button span { display: none; } +.aksrt-widget-search--large { min-height: 58px; border-radius: 14px; } + +.aksrt-widget-toc { padding: 14px 16px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg, #fff); box-shadow: 0 8px 30px rgba(31,35,48,.06); } +.aksrt-widget-toc__toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; padding: 0; border: 0; background: transparent; color: inherit; font-weight: 700; cursor: pointer; } +.aksrt-widget-toc__list { margin: 14px 0 0; padding-left: 20px; max-height: 55vh; overflow: auto; } +.aksrt-widget-toc__list li { margin: 7px 0; color: var(--muted-color, #737582); } +.aksrt-widget-toc__list .level-h3 { padding-left: 12px; font-size: 14px; } +.aksrt-widget-toc__list .level-h4 { padding-left: 24px; font-size: 13px; } +.aksrt-widget-toc.is-collapsed .aksrt-widget-toc__list { display: none; } + +.aksrt-widget-quote { position: relative; min-height: 130px; margin: 0; padding: 24px 48px 24px 25px; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: linear-gradient(135deg, #6772e5, #8b5cf6); color: #fff; } +.aksrt-widget-quote > i { opacity: .35; font-size: 26px; } +.aksrt-widget-quote blockquote { margin: 12px 0 0; font-size: 16px; line-height: 1.8; } +.aksrt-widget-quote button { position: absolute; right: 13px; bottom: 13px; width: 30px; height: 30px; border: 0; border-radius: 50%; background: rgba(255,255,255,.16); color: #fff; cursor: pointer; } + +.aksrt-widget-icon-card, .aksrt-widget-icon-cards .icon-card, .aksrt-widget-icon-cards .icon-cover-card { min-height: 130px; padding: 22px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg, #fff); box-shadow: 0 10px 32px rgba(31,35,48,.07); transition: transform .25s, box-shadow .25s; } +.aksrt-widget-icon-card:hover, .aksrt-widget-icon-cards .icon-card:hover, .aksrt-widget-icon-cards .icon-cover-card:hover { transform: translateY(-4px); box-shadow: 0 16px 40px rgba(31,35,48,.12); } +.aksrt-widget-icon-card { display: flex; flex-direction: column; gap: 12px; text-align: center; } +.aksrt-widget-icon-cards--cover .aksrt-widget-icon-card { flex-direction: row; align-items: center; text-align: left; } +.aksrt-widget-icon-card > i { font-size: 34px; color: var(--focus-color, #ff5473); } +.aksrt-widget-icon-card span { display: flex; flex-direction: column; gap: 5px; } +.aksrt-widget-icon-card small { color: var(--muted-color, #8a8c98); } + +.aksrt-widget-video { position: relative; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: #000; aspect-ratio: 16 / 9; } +.aksrt-widget-video--4-3 { aspect-ratio: 4 / 3; }.aksrt-widget-video--1-1 { aspect-ratio: 1; }.aksrt-widget-video--auto { aspect-ratio: auto; } +.aksrt-widget-video video, .aksrt-widget-video__player, .aksrt-widget-video .dplayer { width: 100%; height: 100%; } +.aksrt-widget-media-error { position: absolute; inset: auto 15px 15px; color: #fff; } +.aksrt-widget-embed { position: relative; height: 0; padding-bottom: var(--aksrt-embed-ratio, 56.25%); overflow: hidden; border-radius: var(--aksrt-widget-radius); background: #111; } +.aksrt-widget-embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; } + +.aksrt-media-card { position: relative; display: block; overflow: hidden; min-height: 180px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg, #fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); } +.aksrt-media-card__media { display: block; aspect-ratio: 16 / 9; overflow: hidden; background: #eceef2; } +.aksrt-media-card__media img, .aksrt-media-card__media video { width: 100%; height: 100%; object-fit: cover; transition: transform .35s; } +.aksrt-media-card:hover .aksrt-media-card__media img { transform: scale(1.04); } +.aksrt-media-card__image--dark { display: none; } +.dark-theme .aksrt-media-card__image--dark, [data-theme="dark"] .aksrt-media-card__image--dark { display: block; } +.dark-theme .aksrt-media-card__image--dark ~ .aksrt-media-card__image, [data-theme="dark"] .aksrt-media-card__image--dark ~ .aksrt-media-card__image { display: none; } +.aksrt-media-card__body { display: flex; flex-direction: column; gap: 5px; padding: 14px 16px 16px; } +.aksrt-media-card__body small { color: var(--muted-color, #8a8c98); } +.aksrt-widget-partners { overflow: hidden; padding: 5px 0; } +.aksrt-widget-partners__track { display: grid; grid-auto-flow: column; grid-template-rows: repeat(var(--aksrt-partner-rows), 76px); grid-auto-columns: 210px; gap: 12px; width: max-content; animation: aksrt-partners var(--aksrt-partner-speed) linear infinite; } +.aksrt-widget-partners:hover .aksrt-widget-partners__track { animation-play-state: paused; } +.aksrt-widget-partners .aksrt-media-card { display: flex; min-height: 76px; align-items: center; } +.aksrt-widget-partners .aksrt-media-card__media { width: 64px; aspect-ratio: 1; margin-left: 8px; border-radius: 9px; } +.aksrt-widget-partners .aksrt-media-card__body { padding: 10px; } +@keyframes aksrt-partners { to { transform: translateX(-50%); } } +.aksrt-combo-card { overflow: hidden; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); } +.aksrt-combo-card .aksrt-media-card { box-shadow: none; } +.aksrt-combo-card__tags { display: flex; flex-wrap: wrap; gap: 5px; padding: 0 16px 16px; } +.aksrt-combo-card__tags span { padding: 3px 8px; border-radius: 999px; background: rgba(127,127,127,.1); font-size: 12px; } + +.aksrt-widget-text-title { padding: 18px 0; }.aksrt-widget-text-title.is-center { text-align: center; }.aksrt-widget-text-title.is-right { text-align: right; } +.aksrt-widget-text-title h2 { margin: 0; font-size: clamp(26px,4vw,44px); line-height: 1.25; }.aksrt-widget-text-title p { margin: 12px 0 0; color: var(--muted-color,#8a8c98); line-height: 1.8; } +.aksrt-widget-buttons { display: flex; flex-wrap: wrap; gap: var(--aksrt-button-gap,10px); }.aksrt-widget-buttons.is-center { justify-content: center; }.aksrt-widget-buttons.is-right { justify-content: flex-end; } +.aksrt-widget-button { display: inline-flex; min-height: 40px; padding: 9px 18px; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 9px; background: var(--focus-color,#ff5473); color: #fff !important; font-weight: 600; cursor: pointer; transition: filter .2s, transform .2s; } +.aksrt-widget-button:hover { filter: brightness(1.07); transform: translateY(-1px); }.aksrt-widget-button.is-ghost { background: rgba(127,127,127,.12); color: inherit !important; } +.aksrt-widget-gap { height: var(--aksrt-gap); } +.aksrt-widget-tag-cloud { display: flex; flex-wrap: wrap; gap: 8px; }.aksrt-widget-tag-cloud a { padding: 6px 11px; border-radius: 6px; background: rgba(127,127,127,.1); font-size: 13px; transition: background .2s,color .2s; }.aksrt-widget-tag-cloud a:hover { background: var(--focus-color,#ff5473); color: #fff; }.aksrt-widget-tag-cloud small { margin-left: 5px; opacity: .65; } +.aksrt-widget-comments { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.aksrt-widget-comments li { display: flex; gap: 11px; }.aksrt-widget-comments__avatar img { width: 42px; height: 42px; border-radius: 50%; }.aksrt-widget-comments p { margin: 3px 0; color: var(--muted-color,#8a8c98); font-size: 13px; line-height: 1.55; }.aksrt-widget-comments__source { font-size: 12px; opacity: .65; } +.aksrt-widget-links { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; }.aksrt-widget-links a { display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 6px 20px rgba(31,35,48,.06); }.aksrt-widget-links small { color: var(--muted-color,#8a8c98); }.aksrt-widget-links--simple { display: flex; flex-wrap: wrap; }.aksrt-widget-links--simple a { padding: 5px 9px; box-shadow: none; background: transparent; } +.aksrt-widget-notice { display: flex; height: 48px; align-items: center; gap: 12px; padding: 0 16px; overflow: hidden; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 7px 22px rgba(31,35,48,.06); }.aksrt-widget-notice .swiper { height: 48px; flex: 1; }.aksrt-widget-notice .swiper-slide, .aksrt-widget-notice .swiper-slide a { display: flex; height: 48px; align-items: center; gap: 10px; }.aksrt-widget-notice .swiper-slide span { overflow: hidden; color: var(--muted-color,#8a8c98); text-overflow: ellipsis; white-space: nowrap; } + +.aksrt-term-card { display: block; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-card__media { display: block; aspect-ratio: 16/9; background: linear-gradient(135deg,#667eea,#764ba2); }.aksrt-term-card__media img { width: 100%; height: 100%; object-fit: cover; }.aksrt-term-card__body { display: flex; flex-direction: column; gap: 5px; padding: 14px; }.aksrt-term-card__body small { min-height: 38px; color: var(--muted-color,#8a8c98); }.aksrt-term-card__body em { font-size: 12px; color: var(--focus-color,#ff5473); font-style: normal; } +.aksrt-term-aggregation { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }.aksrt-term-aggregation__card { padding: 16px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-aggregation header a { display: flex; justify-content: space-between; }.aksrt-term-aggregation header span { color: var(--muted-color,#8a8c98); font-size: 13px; }.aksrt-term-aggregation ul { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }.aksrt-term-aggregation li a { display: flex; align-items: center; gap: 10px; }.aksrt-term-aggregation li img { width: 55px; height: 42px; border-radius: 6px; object-fit: cover; } +.aksrt-widget-items { position: relative; min-height: 40px; }.aksrt-widget-items.is-loading { opacity: .72; pointer-events: none; }.aksrt-widget-skeleton { height: 180px; border-radius: 10px; background: linear-gradient(90deg,rgba(127,127,127,.08),rgba(127,127,127,.17),rgba(127,127,127,.08)); background-size: 200% 100%; animation: aksrt-shimmer 1.2s infinite; }.aksrt-widget-load-more { display: block; min-width: 120px; margin: 18px auto 0; padding: 9px 18px; border: 0; border-radius: 999px; background: rgba(127,127,127,.12); color: inherit; cursor: pointer; }.aksrt-widget-pagination { display:flex;justify-content:center;gap:6px;margin-top:18px; }.aksrt-widget-pagination button { min-width:34px;height:34px;border:0;border-radius:8px;background:rgba(127,127,127,.1);color:inherit;cursor:pointer; }.aksrt-widget-pagination button[aria-current="page"] { background:var(--focus-color,#ff5473);color:#fff; }@keyframes aksrt-shimmer { to { background-position: -200% 0; } } +.aksrt-widget-oneline .swiper-slide { width: min(300px,78vw); }.aksrt-widget-tabs__nav { display: flex; gap: 8px; margin-bottom: 15px; overflow-x: auto; }.aksrt-widget-tabs__nav button { flex: none; padding: 7px 14px; border: 0; border-radius: 999px; background: rgba(127,127,127,.1); color: inherit; cursor: pointer; }.aksrt-widget-tabs__nav button[aria-selected="true"] { background: var(--focus-color,#ff5473); color: #fff; } +.aksrt-post-pay { padding: 20px; border-radius: var(--aksrt-widget-radius); text-align: center; }.aksrt-post-pay.is-authorized { background: rgba(34,197,94,.1); }.aksrt-post-pay > i { display: block; margin-bottom: 9px; color: #22c55e; font-size: 30px; }.aksrt-post-pay p { color: var(--muted-color,#8a8c98); } + +.aksrt-pricing-card { position: relative; display: flex; flex-direction: column; padding: 25px; border: 1px solid rgba(127,127,127,.13); border-radius: 16px; background: var(--main-bg,#fff); box-shadow: 0 10px 35px rgba(31,35,48,.06); }.aksrt-pricing-card.is-featured { border-color: var(--focus-color,#ff5473); transform: translateY(-8px); }.aksrt-pricing-card__badge { position: absolute; top: 12px; right: 12px; padding: 4px 9px; border-radius: 999px; background: var(--focus-color,#ff5473); color: #fff; font-size: 12px; }.aksrt-pricing-card h3 { margin: 0 0 12px; }.aksrt-pricing-card__price { font-size: 34px; font-weight: 800; }.aksrt-pricing-card p { color: var(--muted-color,#8a8c98); }.aksrt-pricing-card ul { flex: 1; display: grid; gap: 9px; margin: 15px 0 22px; padding: 0; list-style: none; }.aksrt-pricing-card li i { margin-right: 8px; color: #22c55e; } +.aksrt-reviews .swiper-slide { width: min(390px,86vw); height: auto; }.aksrt-review-card { height: 100%; padding: 22px; border-radius: 14px; background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-review-card__rating { color: #f6b73c; }.aksrt-review-card blockquote { min-height: 80px; margin: 15px 0; line-height: 1.75; }.aksrt-review-card footer { display: flex; align-items: center; gap: 11px; }.aksrt-review-card__avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; }.aksrt-review-card footer span { display: flex; flex-direction: column; }.aksrt-review-card footer small { color: var(--muted-color,#8a8c98); } +.aksrt-faq { max-width: 880px; margin: auto; }.aksrt-faq__search { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 10px 14px; border: 1px solid rgba(127,127,127,.16); border-radius: 10px; }.aksrt-faq__search input { width: 100%; border: 0; outline: 0; background: transparent; color: inherit; }.aksrt-faq__items { display: grid; gap: 10px; }.aksrt-faq__item { overflow: hidden; border: 1px solid rgba(127,127,127,.13); border-radius: 10px; background: var(--main-bg,#fff); }.aksrt-faq__item > button { display: flex; width: 100%; justify-content: space-between; padding: 15px 17px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }.aksrt-faq__item > div { padding: 0 17px 17px; color: var(--muted-color,#8a8c98); line-height: 1.75; } +.aksrt-stat { padding: 22px; text-align: center; }.aksrt-stat > strong { display: block; color: var(--focus-color,#ff5473); font-size: clamp(32px,5vw,52px); }.aksrt-stat h3 { margin: 5px 0; }.aksrt-stat p { margin: 0; color: var(--muted-color,#8a8c98); }.aksrt-highlight--large { grid-column: span 2; }.aksrt-tilt-case { transform-style: preserve-3d; }.aksrt-tilt-case .aksrt-media-card__body { transform: translateZ(20px); } +.aksrt-feature-intro__tabs { display: flex; justify-content: center; gap: 8px; margin-bottom: 18px; }.aksrt-feature-intro__tabs button { padding: 7px 14px; border: 0; border-radius: 999px; background: rgba(127,127,127,.1); color: inherit; cursor: pointer; }.aksrt-feature-intro__tabs button[aria-selected="true"] { background: var(--focus-color,#ff5473); color: #fff; }.aksrt-feature-intro__panels article { display: grid; grid-template-columns: 1.15fr 1fr; align-items: center; gap: 34px; }.aksrt-feature-intro__copy p, .aksrt-feature-intro__copy li { color: var(--muted-color,#8a8c98); line-height: 1.7; } + +.aksrt-slider { height: var(--aksrt-slider-height); overflow: hidden; border-radius: var(--aksrt-widget-radius); background: #111; }.aksrt-slider .swiper-slide, .aksrt-slider .swiper-slide > a { position: relative; display: block; width: 100%; height: 100%; overflow: hidden; }.aksrt-slider .swiper-slide > img, .aksrt-slider .swiper-slide > video, .aksrt-slider .swiper-slide > a > img, .aksrt-slider .swiper-slide > a > video { width: 100%; height: 100%; object-fit: cover; }.aksrt-slide-copy { position: absolute; z-index: 2; right: 8%; bottom: 12%; left: 8%; color: #fff; text-shadow: 0 2px 12px rgba(0,0,0,.4); }.aksrt-slide-copy.is-center { top: 50%; bottom: auto; text-align: center; transform: translateY(-50%); }.aksrt-slide-copy.is-right-bottom { text-align: right; }.aksrt-slide-copy h3 { margin: 0; font-size: clamp(25px,4vw,44px); }.aksrt-slide-copy p { margin: 8px 0 0; }.aksrt-slide-layer { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }.aksrt-slider-combo { display: grid; grid-template-columns: minmax(0,2fr) minmax(240px,1fr); gap: 15px; }.aksrt-slider-combo__side { display: grid; gap: 15px; }.aksrt-slider-combo__side .aksrt-media-card { min-height: 0; }.aksrt-slider-combo__side .aksrt-media-card__media { aspect-ratio: 2/1; } + +.aksrt-user-card { position: relative; overflow: hidden; border-radius: 14px; background: var(--main-bg,#fff); box-shadow: 0 9px 30px rgba(31,35,48,.07); text-align: center; }.aksrt-user-card__cover { height: 105px; background: linear-gradient(135deg,#667eea,#764ba2); background-position: center; background-size: cover; }.aksrt-user-card__body { position: relative; padding: 18px; }.aksrt-user-card__avatar { display: inline-block; margin-top: -62px; padding: 4px; border-radius: 50%; background: var(--main-bg,#fff); }.aksrt-user-card__avatar img { display: block; width: 84px; height: 84px; border-radius: 50%; object-fit: cover; }.aksrt-user-card.is-guest .aksrt-user-card__avatar { margin-top: -58px; }.aksrt-user-card h3 { margin: 7px 0; }.aksrt-user-card__identity { margin: 6px 0; }.aksrt-user-card__body > p { color: var(--muted-color,#8a8c98); }.aksrt-user-card__stats { display: flex; justify-content: center; gap: 24px; margin: 16px 0; }.aksrt-user-card__stats span { display: flex; flex-direction: column; font-size: 12px; color: var(--muted-color,#8a8c98); }.aksrt-user-card__stats b { color: inherit; font-size: 17px; }.aksrt-user-card__actions { display: flex; justify-content: center; gap: 9px; }.aksrt-user-card__follow { position: absolute; top: 14px; right: 14px; padding: 5px 12px; border: 0; border-radius: 999px; background: var(--focus-color,#ff5473); color: #fff; cursor: pointer; }.aksrt-user-card__follow.is-following { background: rgba(0,0,0,.5); }.aksrt-user-card__posts { padding: 0 16px 16px; text-align: left; } +.aksrt-user-ranking { display: grid; gap: 11px; margin: 0; padding: 0; list-style: none; }.aksrt-user-ranking li { display: flex; align-items: center; gap: 9px; }.aksrt-user-ranking__index { width: 24px; text-align: center; color: var(--muted-color,#8a8c98); font-weight: 700; }.aksrt-user-ranking__index.is-top { color: #f59e0b; }.aksrt-user-tile { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }.aksrt-user-tile img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; }.aksrt-user-tile span { display: flex; min-width: 0; flex-direction: column; }.aksrt-user-tile strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.aksrt-user-tile small { color: var(--muted-color,#8a8c98); }.aksrt-user-list { --aksrt-cols: 4; }.aksrt-user-list article { padding: 14px; border-radius: 10px; background: var(--main-bg,#fff); box-shadow: 0 6px 20px rgba(31,35,48,.06); } +.aksrt-user-card.no-cover .aksrt-user-card__avatar { margin-top: 0; }.aksrt-user-card__oauth { margin-top: 14px; } + +@media (max-width: 767px) { + .aksrt-widget { margin: var(--aksrt-widget-space-m-top,0) var(--aksrt-widget-space-m-right,0) var(--aksrt-widget-space-m-bottom,15px) var(--aksrt-widget-space-m-left,0); } + .aksrt-widget--only-pc { display: none; }.aksrt-widget--only-mobile { display: block; } + .aksrt-widget-grid { grid-template-columns: repeat(var(--aksrt-cols-m,1),minmax(0,1fr)); gap: 10px; } + .aksrt-widget__header { align-items: flex-start; flex-direction: column; }.aksrt-widget__title-links { justify-content: flex-start; } + .aksrt-widget-links { grid-template-columns: repeat(2,minmax(0,1fr)); } + .aksrt-widget-partners__track { grid-template-rows: repeat(var(--aksrt-partner-rows-m),70px); } + .aksrt-term-aggregation, .aksrt-feature-intro__panels article, .aksrt-slider-combo { grid-template-columns: 1fr; } + .aksrt-highlight--large { grid-column: auto; } + .aksrt-slider { height: var(--aksrt-slider-height-m); }.aksrt-slide--hide-mobile { display: none !important; } + .aksrt-widget-gap { height: var(--aksrt-gap-m); }.aksrt-pricing-card.is-featured { transform: none; } + .aksrt-user-list { --aksrt-cols-m: 1; } +} +@media (prefers-reduced-motion: reduce) { + .aksrt-widget *, .aksrt-widget *::before, .aksrt-widget *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !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; } +@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 new file mode 100644 index 00000000..7e387aa3 --- /dev/null +++ b/assets/widgets/frontend.js @@ -0,0 +1,290 @@ +(function () { + 'use strict'; + + var config = window.aksrtWidgets || {}; + var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + function once(el, key) { + var name = 'aksrt' + key; + if (el.dataset[name]) return false; + el.dataset[name] = '1'; + return true; + } + + function initSwiper(el) { + if (!once(el, 'Swiper') || typeof window.Swiper !== 'function') return; + var kind = el.dataset.aksrtSwiper || 'cards'; + var vertical = kind === 'notice' || el.dataset.direction === 'vertical'; + var options = { + direction: vertical ? 'vertical' : 'horizontal', + loop: el.dataset.loop === '1' || kind === 'notice', + speed: Number(el.dataset.speed || 500), + spaceBetween: Number(el.dataset.space || 16), + observer: true, + observeParents: true, + watchOverflow: true, + navigation: { + nextEl: el.querySelector('.swiper-button-next'), + prevEl: el.querySelector('.swiper-button-prev') + }, + pagination: { el: el.querySelector('.swiper-pagination'), clickable: true } + }; + if (kind === 'scroll' || kind === 'cards') { + options.slidesPerView = 'auto'; + options.freeMode = kind === 'scroll'; + } + if (kind === 'slider') { + options.effect = el.dataset.effect || 'slide'; + options.parallax = !!el.querySelector('[data-swiper-parallax]'); + } + if (!reducedMotion && (el.dataset.autoplay === '1' || kind === 'notice')) { + options.autoplay = { delay: Number(el.dataset.interval || 4000), disableOnInteraction: false, pauseOnMouseEnter: true }; + } + try { + el.aksrtSwiper = new window.Swiper(el, options); + } catch (error) { + el.classList.add('is-fallback'); + } + } + + function videoType(url) { + if (/\.m3u8(?:\?|$)/i.test(url)) return 'hls'; + if (/\.flv(?:\?|$)/i.test(url)) return 'flv'; + if (/\.mpd(?:\?|$)/i.test(url)) return 'dash'; + return 'auto'; + } + + function initVideo(el) { + if (!once(el, 'Video')) return; + var fallback = el.querySelector('video'); + if (fallback) { + fallback.addEventListener('error', function () { + var message = el.querySelector('.aksrt-widget-media-error'); + if (message) message.hidden = false; + }); + } + if (typeof window.DPlayer !== 'function') return; + var holder = document.createElement('div'); + holder.className = 'aksrt-widget-video__player'; + el.insertBefore(holder, fallback || el.firstChild); + try { + el.aksrtPlayer = new window.DPlayer({ + container: holder, + autoplay: el.dataset.autoplay === '1' && !reducedMotion, + video: { + url: el.dataset.videoUrl || '', + pic: el.dataset.videoPoster || '', + type: videoType(el.dataset.videoUrl || '') + } + }); + if (el.dataset.muted === '1' && el.aksrtPlayer.video) el.aksrtPlayer.video.muted = true; + if (fallback) fallback.hidden = true; + } catch (error) { + holder.remove(); + } + } + + function initToc(toc) { + if (!once(toc, 'Toc')) return; + var scope = document.querySelector('.article-content, .wp-posts-content, article .entry-content'); + var headings = scope ? Array.prototype.slice.call(scope.querySelectorAll('h2,h3,h4')) : []; + var minimum = Number(toc.dataset.minHeadings || 3); + var list = toc.querySelector('.aksrt-widget-toc__list'); + if (headings.length < minimum || !list) { + toc.hidden = true; + return; + } + headings.forEach(function (heading, index) { + if (!heading.id) heading.id = 'aksrt-heading-' + (index + 1); + var item = document.createElement('li'); + item.className = 'level-' + heading.tagName.toLowerCase(); + var link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent.trim(); + link.addEventListener('click', function (event) { + event.preventDefault(); + window.scrollTo({ top: heading.getBoundingClientRect().top + window.scrollY - Number(toc.dataset.scrollOffset || 70), behavior: reducedMotion ? 'auto' : 'smooth' }); + history.replaceState(null, '', link.href); + }); + item.appendChild(link); + list.appendChild(item); + }); + var empty = toc.querySelector('.aksrt-widget-toc__empty'); + if (empty) empty.remove(); + var toggle = toc.querySelector('.aksrt-widget-toc__toggle'); + if (toggle) toggle.addEventListener('click', function () { + var expanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true'); + toc.classList.toggle('is-collapsed', expanded); + }); + } + + function updateQuote(figure) { + if (figure.dataset.source !== 'api' || !config.quoteUrl) return Promise.resolve(); + var button = figure.querySelector('button'); + if (button) button.disabled = true; + return fetch(config.quoteUrl, { credentials: 'same-origin', headers: { Accept: 'application/json' } }) + .then(function (response) { if (!response.ok) throw new Error('quote'); return response.json(); }) + .then(function (data) { + if (data && data.text) figure.querySelector('blockquote').textContent = data.text; + }) + .catch(function () {}) + .finally(function () { if (button) button.disabled = false; }); + } + + function initQuote(figure) { + if (!once(figure, 'Quote')) return; + var button = figure.querySelector('button'); + if (button) button.addEventListener('click', function () { updateQuote(figure); }); + if (!reducedMotion && figure.dataset.source === 'api') { + window.setInterval(function () { if (!document.hidden) updateQuote(figure); }, Math.max(5, Number(figure.dataset.interval || 30)) * 1000); + } + } + + function errorMarkup() { + var i18n = config.i18n || {}; + return ''; + } + + function itemsUrl(widget, instance, page, segment) { + var base = String(config.restUrl || '').replace(/\/$/, ''); + var url = base + '/' + encodeURIComponent(widget) + '/items?instance=' + encodeURIComponent(instance) + '&page=' + encodeURIComponent(page); + if (segment !== undefined && segment !== '') url += '&segment=' + encodeURIComponent(segment); + return url; + } + + function loadItems(container, page, append) { + var owner = container.closest('[data-widget]') || container; + var widget = owner.dataset.widget || container.dataset.widget; + var instance = owner.dataset.instance || container.dataset.instance; + var segment = container.dataset.segment || owner.dataset.segment || ''; + if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return; + container.dataset.loading = '1'; + container.classList.add('is-loading'); + fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } }) + .then(function (response) { if (!response.ok) throw new Error('items'); return response.json(); }) + .then(function (data) { + var oldPagination = container.querySelector('.aksrt-widget-pagination, .aksrt-widget-load-more'); + if (oldPagination) oldPagination.remove(); + var fragment = document.createElement('div'); + fragment.innerHTML = String(data.html || ''); + if (append) { + var target = container.querySelector('.aksrt-widget-posts, .posts-row, .swiper-wrapper') || container; + var source = fragment.querySelector('.aksrt-widget-posts, .posts-row, .swiper-wrapper'); + Array.prototype.slice.call((source || fragment).children).forEach(function (node) { target.appendChild(node); }); + } else { + container.innerHTML = fragment.innerHTML; + } + if (data.paginate_html) container.insertAdjacentHTML('beforeend', data.paginate_html); + container.dataset.page = String(data.page || page); + scan(container); + }) + .catch(function () { container.innerHTML = errorMarkup(); }) + .finally(function () { delete container.dataset.loading; container.classList.remove('is-loading'); }); + } + + function initItems(container) { + if (!once(container, 'Items')) return; + if (container.classList.contains('is-loading')) loadItems(container, 1, false); + container.addEventListener('click', function (event) { + var retry = event.target.closest('[data-widget-retry]'); + var more = event.target.closest('.aksrt-widget-load-more'); + var pageButton = event.target.closest('[data-widget-page]'); + if (retry) loadItems(container, Number(container.dataset.page || 1), false); + if (more) loadItems(container, Number(more.dataset.nextPage || 2), true); + if (pageButton) loadItems(container, Number(pageButton.dataset.widgetPage || 1), false); + }); + if ('IntersectionObserver' in window) { + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + var more = container.querySelector('.aksrt-widget-load-more[data-mode="infinite"]'); + if (entry.isIntersecting && more) loadItems(container, Number(more.dataset.nextPage || 2), true); + }); + }, { rootMargin: '240px' }); + observer.observe(container); + } + } + + function initTabs(tabs) { + if (!once(tabs, 'Tabs')) return; + tabs.addEventListener('click', function (event) { + var button = event.target.closest('[data-segment]'); + if (!button) return; + tabs.querySelectorAll('[role="tab"]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); + var panel = tabs.querySelector('[data-widget-items]'); + panel.dataset.segment = button.dataset.segment; + loadItems(panel, 1, false); + }); + } + + function initFaq(faq) { + if (!once(faq, 'Faq')) return; + faq.addEventListener('click', function (event) { + var button = event.target.closest('.aksrt-faq__item > button'); + if (!button) return; + var expanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', expanded ? 'false' : 'true'); + var panel = document.getElementById(button.getAttribute('aria-controls')); + if (panel) panel.hidden = expanded; + }); + var search = faq.querySelector('input[type="search"]'); + if (search) search.addEventListener('input', function () { + var needle = search.value.trim().toLowerCase(); + faq.querySelectorAll('.aksrt-faq__item').forEach(function (item) { item.hidden = needle && String(item.dataset.search || '').toLowerCase().indexOf(needle) === -1; }); + }); + } + + function initFeatures(features) { + if (!once(features, 'Features')) return; + features.addEventListener('click', function (event) { + var button = event.target.closest('[data-feature-index]'); + if (!button) return; + var index = button.dataset.featureIndex; + features.querySelectorAll('[data-feature-index]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); + features.querySelectorAll('[data-feature-panel]').forEach(function (panel) { panel.hidden = panel.dataset.featurePanel !== index; }); + }); + } + + function initCount(el) { + if (!once(el, 'Count') || reducedMotion) return; + var raw = el.dataset.countUp || ''; + var match = raw.match(/-?[0-9]+(?:\.[0-9]+)?/); + if (!match) return; + var target = Number(match[0]); + var prefix = raw.slice(0, match.index); + var suffix = raw.slice(match.index + match[0].length); + var start = performance.now(); + function frame(now) { + var progress = Math.min(1, (now - start) / 900); + el.textContent = prefix + Math.round(target * (1 - Math.pow(1 - progress, 3))) + suffix; + if (progress < 1) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + } + + function scan(root) { + var scope = root || document; + scope.querySelectorAll('[data-aksrt-swiper]').forEach(initSwiper); + scope.querySelectorAll('.aksrt-widget-video[data-video-url]').forEach(initVideo); + 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-faq').forEach(initFaq); + scope.querySelectorAll('.aksrt-feature-intro').forEach(initFeatures); + scope.querySelectorAll('[data-count-up]').forEach(initCount); + if (typeof window.VanillaTilt !== 'undefined') scope.querySelectorAll('[data-aksrt-tilt]').forEach(function (el) { if (once(el, 'Tilt')) window.VanillaTilt.init(el); }); + } + + function ready() { + scan(document); + if ('MutationObserver' in window) { + new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { mutation.addedNodes.forEach(function (node) { if (node.nodeType === 1) scan(node); }); }); + }).observe(document.body, { childList: true, subtree: true }); + } + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready); + else ready(); +})(); diff --git a/src/WidgetSystem/AdminFormController.php b/src/WidgetSystem/AdminFormController.php new file mode 100644 index 00000000..84238b22 --- /dev/null +++ b/src/WidgetSystem/AdminFormController.php @@ -0,0 +1,64 @@ +id, ['widgets', 'customize'], true)) { + return; + } + wp_enqueue_media(); + wp_enqueue_style('wp-color-picker'); + wp_enqueue_script('jquery-ui-sortable'); + wp_enqueue_style('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.css', ['wp-color-picker'], AKSRT_VERSION); + wp_enqueue_script('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.js', ['jquery', 'jquery-ui-sortable', 'wp-color-picker'], AKSRT_VERSION, true); + wp_localize_script('aksrt-widget-admin', 'aksrtWidgetAdmin', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'action' => self::AJAX_ACTION, + 'nonce' => wp_create_nonce(self::NONCE_ACTION), + 'i18n' => [ + 'loading' => __('正在加载模块配置…', 'aksrt'), + 'error' => __('加载失败,请重试。', 'aksrt'), + ], + ]); + } + + public static function ajax_form(): void { + check_ajax_referer(self::NONCE_ACTION, 'nonce'); + if (!current_user_can('edit_theme_options')) { + wp_send_json_error(['message' => __('权限不足。', 'aksrt')], 403); + } + + $id_base = isset($_POST['id_base']) ? sanitize_key(wp_unslash($_POST['id_base'])) : ''; + $number = isset($_POST['widget_number']) ? absint($_POST['widget_number']) : 0; + $definition = WidgetRegistry::get_by_id_base($id_base); + if (!$definition || $number < 1) { + wp_send_json_error(['message' => __('无效的小工具实例。', 'aksrt')], 400); + } + + $widget = new SchemaWidget($definition); + $widget->_set($number); + $settings = $widget->get_settings(); + $instance = isset($settings[$number]) && is_array($settings[$number]) ? $settings[$number] : []; + ob_start(); + $widget->render_full_form($instance); + $html = (string) ob_get_clean(); + wp_send_json_success(['html' => $html]); + } +} + diff --git a/src/WidgetSystem/AssetManager.php b/src/WidgetSystem/AssetManager.php new file mode 100644 index 00000000..474665e9 --- /dev/null +++ b/src/WidgetSystem/AssetManager.php @@ -0,0 +1,72 @@ +assets() as $asset) { + self::enqueue_optional($asset); + } + wp_enqueue_style('aksrt-widgets'); + wp_enqueue_script('aksrt-widgets'); + if (!self::$configured) { + $config = [ + 'restUrl' => esc_url_raw(rest_url('aksrt/v1/widgets/')), + 'quoteUrl' => esc_url_raw(rest_url('aksrt/v1/widgets/quote')), + 'i18n' => [ + 'loading' => __('正在加载…', 'aksrt'), + 'error' => __('加载失败,请重试。', 'aksrt'), + 'retry' => __('重试', 'aksrt'), + ], + ]; + wp_add_inline_script('aksrt-widgets', 'window.aksrtWidgets=' . wp_json_encode($config) . ';', 'before'); + self::$configured = true; + } + } + + private static function register(): void { + if (self::$registered) { + return; + } + $root = get_template_directory(); + $uri = get_template_directory_uri(); + $css = $root . '/assets/widgets/frontend.css'; + $js = $root . '/assets/widgets/frontend.js'; + wp_register_style('aksrt-widgets', $uri . '/assets/widgets/frontend.css', [], is_file($css) ? (string) filemtime($css) : null); + wp_register_script('aksrt-widgets', $uri . '/assets/widgets/frontend.js', [], is_file($js) ? (string) filemtime($js) : null, true); + if (!wp_style_is('aksrt-swiper', 'registered')) { + wp_register_style('aksrt-swiper', $uri . '/assets/css/frontend/swiper-bundle.min.css', [], '11.0'); + } + if (!wp_script_is('aksrt-swiper', 'registered')) { + wp_register_script('aksrt-swiper', $uri . '/assets/js/swiper-bundle.min.js', [], '11.0', true); + } + wp_register_style('aksrt-widget-dplayer', 'https://cdn.jsdelivr.net/npm/dplayer@1.27.1/dist/DPlayer.min.css', [], '1.27.1'); + wp_register_script('aksrt-widget-hls', 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js', [], '1.5.18', true); + wp_register_script('aksrt-widget-flv', 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js', [], '1.6.2', true); + wp_register_script('aksrt-widget-dash', 'https://cdn.jsdelivr.net/npm/dashjs@4.7.4/dist/dash.all.min.js', [], '4.7.4', true); + wp_register_script('aksrt-widget-dplayer', 'https://cdn.jsdelivr.net/npm/dplayer@1.27.1/dist/DPlayer.min.js', ['aksrt-widget-hls', 'aksrt-widget-flv', 'aksrt-widget-dash'], '1.27.1', true); + wp_register_script('aksrt-widget-tilt', 'https://cdn.jsdelivr.net/npm/vanilla-tilt@1.8.1/dist/vanilla-tilt.min.js', [], '1.8.1', true); + self::$registered = true; + } + + private static function enqueue_optional(string $asset): void { + if ($asset === 'swiper') { + wp_enqueue_style('aksrt-swiper'); + wp_enqueue_script('aksrt-swiper'); + } elseif ($asset === 'dplayer') { + wp_enqueue_style('aksrt-widget-dplayer'); + wp_enqueue_script('aksrt-widget-dplayer'); + } elseif (in_array($asset, ['hls', 'flv', 'dash'], true)) { + wp_enqueue_script('aksrt-widget-' . $asset); + } elseif ($asset === 'tilt') { + wp_enqueue_script('aksrt-widget-tilt'); + } + } +} diff --git a/src/WidgetSystem/CommonFields.php b/src/WidgetSystem/CommonFields.php new file mode 100644 index 00000000..a5acc533 --- /dev/null +++ b/src/WidgetSystem/CommonFields.php @@ -0,0 +1,91 @@ + 'sidebar_affix', 'type' => 'switcher', 'label' => '侧栏随动', 'description' => '仅在侧边栏有效', 'default' => false]; + } + if (!empty($flags['visibility'])) { + $fields = array_merge($fields, self::visibility_fields()); + } + if (!empty($flags['animation'])) { + $fields = array_merge($fields, self::animation_fields()); + } + if (!empty($flags['layout'])) { + $fields = array_merge($fields, self::layout_fields()); + } + return $fields; + } + + private static function title_fields(): array { + $link = static fn(string $id, string $label) => ['id' => $id, 'type' => 'link', 'label' => $label, 'default' => ['url' => '', 'text' => '', 'target' => '']]; + return [ + ['id' => 'title', 'type' => 'textarea', 'label' => '模块标题', 'default' => ''], + ['id' => 'subtitle', 'type' => 'textarea', 'label' => '副标题', 'default' => '', 'dependency' => ['title', '!=', '']], + ['id' => 'title_style', 'type' => 'radio', 'label' => '标题样式', 'default' => '', 'options' => ['' => '简约', 'big' => '居中大标题'], 'dependency' => ['title', '!=', '']], + ['id' => 'title_highlight', 'type' => 'text', 'label' => '标题选字高亮', 'default' => '', 'dependency' => ['title', '!=', '']], + ['id' => 'title_highlight_color', 'type' => 'palette', 'label' => '高亮颜色', 'default' => 'focus-color', 'options' => self::palette()], + $link('title_link', '标题链接'), + $link('title_link2', '标题链接 2'), + $link('title_link3', '标题链接 3'), + [ + 'id' => 'title_link_style', 'type' => 'accordion', 'label' => '标题链接样式', + 'fields' => [ + ['id' => 'color_1', 'type' => 'palette', 'label' => '链接 1 颜色', 'default' => 'jb-blue', 'options' => self::palette()], + ['id' => 'color_2', 'type' => 'palette', 'label' => '链接 2 颜色', 'default' => 'jb-yellow', 'options' => self::palette()], + ['id' => 'color_3', 'type' => 'palette', 'label' => '链接 3 颜色', 'default' => 'jb-green', 'options' => self::palette()], + ], + ], + ]; + } + + private static function visibility_fields(): array { + return [ + ['id' => 'show_type', 'type' => 'radio', 'label' => '显示规则', 'default' => 'all', 'options' => ['all' => '全部显示', 'only_pc' => '仅 PC 端', 'only_sm' => '仅移动端']], + ['id' => 'show_id_type', 'type' => 'radio', 'label' => '按 ID 显示或隐藏', 'default' => '', 'options' => ['' => '不限制', 'show' => '仅指定 ID 显示', 'hide' => '指定 ID 隐藏']], + ['id' => 'show_ids', 'type' => 'text', 'label' => '文章 ID', 'description' => '多个 ID 使用逗号分隔', 'default' => '', 'sanitize' => 'ids', 'dependency' => ['show_id_type', '!=', '']], + ]; + } + + private static function animation_fields(): array { + return [ + ['id' => 'animation_in', 'type' => 'select', 'label' => '模块入场动画', 'default' => '', 'options' => ['' => '无动画', 'fade' => '淡入', 'slideup' => '从下往上', 'slidedown' => '从上往下', 'slideright' => '从左往右', 'slideleft' => '从右往左', 'zoomin' => '由小变大', 'zoomout' => '由大变小']], + ['id' => 'animation_repeat', 'type' => 'switcher', 'label' => '重复动画', 'default' => false], + ]; + } + + private static function layout_fields(): array { + return [ + ['id' => 'layout_pin_pc', 'type' => 'spacing', 'label' => '模块额外间距(PC)', 'default' => ['top' => '', 'right' => '', 'bottom' => '', 'left' => '']], + ['id' => 'layout_pin_m', 'type' => 'spacing', 'label' => '模块额外间距(移动端)', 'default' => ['top' => '', 'right' => '', 'bottom' => '', 'left' => '']], + [ + 'id' => 'layout_bg', 'type' => 'accordion', 'label' => '区块背景', + 'fields' => [ + ['id' => 'light', 'type' => 'background', 'label' => '日间模式背景', 'default' => []], + ['id' => 'dark', 'type' => 'background', 'label' => '夜间模式背景', 'default' => []], + ], + ], + ]; + } + + public static function palette(): array { + return [ + 'focus-color' => '主题色', 'jb-blue' => '渐变蓝', 'jb-cyan' => '渐变青', + 'jb-green' => '渐变绿', 'jb-yellow' => '渐变黄', 'jb-red' => '渐变红', + 'jb-pink' => '渐变粉', 'jb-purple' => '渐变紫', 'c-blue' => '蓝色', + 'c-green' => '绿色', 'c-yellow' => '黄色', 'c-red' => '红色', + ]; + } +} + diff --git a/src/WidgetSystem/FieldSchema.php b/src/WidgetSystem/FieldSchema.php new file mode 100644 index 00000000..099ebab5 --- /dev/null +++ b/src/WidgetSystem/FieldSchema.php @@ -0,0 +1,195 @@ += 1 ? (int) round($number) : $number; + } + + private static function choice(array $field, $value) { + $options = (array) ($field['options'] ?? []); + if (!empty($field['multiple'])) { + $values = is_array($value) ? $value : []; + return array_values(array_filter(array_map('strval', $values), static fn($item) => array_key_exists($item, $options))); + } + $key = (string) $value; + return array_key_exists($key, $options) ? $key : (string) ($field['default'] ?? array_key_first($options) ?? ''); + } + + private static function checkbox(array $field, $value) { + $options = (array) ($field['options'] ?? []); + if (!$options) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + $values = is_array($value) ? $value : []; + return array_values(array_filter(array_map('strval', $values), static fn($item) => array_key_exists($item, $options))); + } + + private static function link($value): array { + $value = is_array($value) ? $value : []; + return [ + 'url' => esc_url_raw((string) ($value['url'] ?? '')), + 'text' => sanitize_text_field((string) ($value['text'] ?? '')), + 'target' => ($value['target'] ?? '') === '_blank' ? '_blank' : '', + ]; + } + + private static function spacing($value): array { + $value = is_array($value) ? $value : []; + $result = []; + foreach (['top', 'right', 'bottom', 'left'] as $edge) { + $result[$edge] = isset($value[$edge]) && is_numeric($value[$edge]) ? (int) $value[$edge] : ''; + } + return $result; + } + + private static function background($value): array { + $value = is_array($value) ? $value : []; + return [ + 'color' => self::color((string) ($value['color'] ?? '')), + 'image' => esc_url_raw((string) ($value['image'] ?? '')), + 'position' => self::allow((string) ($value['position'] ?? 'center center'), ['center center', 'top center', 'bottom center', 'center left', 'center right'], 'center center'), + 'repeat' => self::allow((string) ($value['repeat'] ?? 'no-repeat'), ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'], 'no-repeat'), + 'attachment' => self::allow((string) ($value['attachment'] ?? 'scroll'), ['scroll', 'fixed'], 'scroll'), + 'size' => self::allow((string) ($value['size'] ?? 'cover'), ['cover', 'contain', 'auto'], 'cover'), + ]; + } + + private static function group(array $field, $value): array { + $rows = is_array($value) ? array_values($value) : []; + $max = max(1, (int) ($field['max_items'] ?? 50)); + $children = (array) ($field['fields'] ?? []); + return array_map(static fn($row) => self::normalize($children, is_array($row) ? $row : []), array_slice($rows, 0, $max)); + } + + private static function nested(array $field, $value): array { + $children = (array) ($field['fields'] ?? []); + return self::normalize($children, is_array($value) ? $value : []); + } + + private static function color(string $value): string { + $value = trim($value); + if ($value === '') { + return ''; + } + $hex = sanitize_hex_color($value); + if ($hex) { + return $hex; + } + return preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', $value) ? $value : ''; + } + + private static function allow(string $value, array $allowed, string $fallback): string { + return in_array($value, $allowed, true) ? $value : $fallback; + } +} + diff --git a/src/WidgetSystem/FormRenderer.php b/src/WidgetSystem/FormRenderer.php new file mode 100644 index 00000000..7174f321 --- /dev/null +++ b/src/WidgetSystem/FormRenderer.php @@ -0,0 +1,232 @@ +'; + foreach ($fields as $field) { + self::field($widget, $field, $instance[(string) ($field['id'] ?? '')] ?? ($field['default'] ?? ''), ''); + } + echo ''; + } + + private static function field(SchemaWidget $widget, array $field, $value, string $parent): void { + $type = (string) ($field['type'] ?? 'text'); + $id = (string) ($field['id'] ?? ''); + if (in_array($type, ['content', 'subheading', 'submessage'], true)) { + self::display_field($field, $type); + return; + } + if ($id === '') { + return; + } + + $key = $parent === '' ? $id : $parent . '[' . $id . ']'; + $field_id = $widget->get_field_id(str_replace(['[', ']'], ['-', ''], $key)); + $field_name = $widget->get_field_name($key); + $dependency = !empty($field['dependency']) ? wp_json_encode($field['dependency']) : ''; + + echo '
'; + if (!empty($field['label'])) { + echo ''; + } + if (!empty($field['description'])) { + echo '

' . wp_kses_post((string) $field['description']) . '

'; + } + + switch ($type) { + case 'switcher': + self::switcher($field_id, $field_name, (bool) $value); + break; + case 'radio': + case 'button_set': + case 'palette': + self::radios($field_id, $field_name, (array) ($field['options'] ?? []), (string) $value, $type); + break; + case 'select': + self::select($field_id, $field_name, $field, $value); + break; + case 'checkbox': + self::checkboxes($field_id, $field_name, $field, $value); + break; + case 'number': + case 'spinner': + case 'slider': + self::number($field_id, $field_name, $field, $value, $type); + break; + case 'textarea': + echo ''; + break; + case 'upload': + self::upload($field_id, $field_name, (string) $value); + break; + case 'color': + echo ''; + break; + case 'icon': + echo '
'; + break; + case 'link': + self::link($widget, $key, is_array($value) ? $value : []); + break; + case 'spacing': + self::spacing($widget, $key, is_array($value) ? $value : []); + break; + case 'background': + self::background($widget, $key, is_array($value) ? $value : []); + break; + case 'group': + self::group($widget, $field, is_array($value) ? $value : [], $key); + break; + case 'fieldset': + case 'accordion': + self::nested($widget, $field, is_array($value) ? $value : [], $key); + break; + case 'text': + default: + echo ''; + } + echo '
'; + } + + private static function display_field(array $field, string $type): void { + $content = (string) ($field['content'] ?? $field['label'] ?? ''); + echo '
' . wp_kses_post($content) . '
'; + } + + private static function switcher(string $id, string $name, bool $value): void { + echo ''; + echo ''; + } + + private static function radios(string $id, string $name, array $options, string $value, string $type): void { + echo '
'; + foreach ($options as $key => $label) { + $option_id = $id . '-' . sanitize_html_class((string) $key); + echo ''; + } + echo '
'; + } + + private static function select(string $id, string $name, array $field, $value): void { + $multiple = !empty($field['multiple']); + echo ''; + } + + private static function checkboxes(string $id, string $name, array $field, $value): void { + $options = (array) ($field['options'] ?? []); + if (!$options) { + self::switcher($id, $name, (bool) $value); + return; + } + $values = array_map('strval', (array) $value); + foreach ($options as $key => $label) { + echo ' '; + } + } + + private static function number(string $id, string $name, array $field, $value, string $type): void { + $min = isset($field['min']) ? ' min="' . esc_attr((string) $field['min']) . '"' : ''; + $max = isset($field['max']) ? ' max="' . esc_attr((string) $field['max']) . '"' : ''; + $step = ' step="' . esc_attr((string) ($field['step'] ?? 1)) . '"'; + $input_type = $type === 'slider' ? 'range' : 'number'; + echo '
' . esc_html((string) $value) . '
'; + } + + private static function upload(string $id, string $name, string $value): void { + echo '
'; + if ($value !== '') { + echo ''; + } + echo ''; + echo '
'; + } + + private static function link(SchemaWidget $widget, string $key, array $value): void { + echo ''; + } + + private static function spacing(SchemaWidget $widget, string $key, array $value): void { + echo '
'; + foreach (['top' => '上', 'right' => '右', 'bottom' => '下', 'left' => '左'] as $edge => $label) { + echo ''; + } + echo '
'; + } + + private static function background(SchemaWidget $widget, string $key, array $value): void { + echo '
'; + self::plain_input($widget, $key . '[color]', (string) ($value['color'] ?? ''), 'text', '背景颜色'); + self::plain_input($widget, $key . '[image]', (string) ($value['image'] ?? ''), 'url', '背景图片 URL'); + foreach ([ + 'position' => ['center center' => '居中', 'top center' => '顶部', 'bottom center' => '底部', 'center left' => '左侧', 'center right' => '右侧'], + 'repeat' => ['no-repeat' => '不重复', 'repeat' => '平铺', 'repeat-x' => '水平平铺', 'repeat-y' => '垂直平铺'], + 'attachment' => ['scroll' => '滚动', 'fixed' => '固定'], + 'size' => ['cover' => '覆盖', 'contain' => '包含', 'auto' => '原始尺寸'], + ] as $part => $options) { + $name = $widget->get_field_name($key . '[' . $part . ']'); + echo ''; + } + echo '
'; + } + + private static function group(SchemaWidget $widget, array $field, array $rows, string $key): void { + $token = '__index_' . substr(md5($key), 0, 8) . '__'; + echo '
'; + foreach ($rows as $index => $row) { + self::group_row($widget, $field, is_array($row) ? $row : [], $key, (string) $index); + } + echo '
'; + } + + private static function group_row(SchemaWidget $widget, array $field, array $row, string $key, string $index): void { + echo '
' . esc_html__('项目', 'aksrt') . '
'; + foreach ((array) ($field['fields'] ?? []) as $child) { + $child_id = (string) ($child['id'] ?? ''); + self::field($widget, $child, $row[$child_id] ?? ($child['default'] ?? ''), $key . '[' . $index . ']'); + } + echo '
'; + } + + private static function nested(SchemaWidget $widget, array $field, array $value, string $key): void { + echo '
' . esc_html((string) ($field['label'] ?? '更多设置')) . '
'; + foreach ((array) ($field['fields'] ?? []) as $child) { + $child_id = (string) ($child['id'] ?? ''); + self::field($widget, $child, $value[$child_id] ?? ($child['default'] ?? ''), $key); + } + echo '
'; + } + + private static function plain_input(SchemaWidget $widget, string $key, string $value, string $type, string $placeholder): void { + $name = $widget->get_field_name($key); + $id = $widget->get_field_id(str_replace(['[', ']'], ['-', ''], $key)); + echo ''; + } +} diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php new file mode 100644 index 00000000..e77caaaf --- /dev/null +++ b/src/WidgetSystem/FrontendRenderer.php @@ -0,0 +1,110 @@ + (string) $widget->id, + 'id_base' => $definition->id_base(), + ]; + $content = RendererRegistry::render($definition->renderer(), $instance, $context); + if ($content === '' && $definition->slug() !== 'layout_gap') { + return; + } + + AssetManager::enqueue_for($definition); + $classes = ['aksrt-widget', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; + if (($instance['show_type'] ?? 'all') === 'only_pc') { + $classes[] = 'aksrt-widget--only-pc'; + } elseif (($instance['show_type'] ?? 'all') === 'only_sm') { + $classes[] = 'aksrt-widget--only-mobile'; + } + if (!empty($instance['sidebar_affix'])) { + $classes[] = 'aksrt-widget--affix'; + } + if (!empty($instance['animation_in'])) { + $classes[] = 'aksrt-widget--animate'; + $classes[] = 'aksrt-widget--animate-' . sanitize_html_class((string) $instance['animation_in']); + } + if (!empty($instance['animation_repeat'])) { + $classes[] = 'aksrt-widget--animate-repeat'; + } + + $style = self::style($instance); + echo (string) ($args['before_widget'] ?? '
'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + echo self::title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
' . $content . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + echo (string) ($args['after_widget'] ?? '
'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + private static function is_visible(array $instance): bool { + $type = (string) ($instance['show_id_type'] ?? ''); + $ids = RendererHelpers::ids((string) ($instance['show_ids'] ?? '')); + if ($type === '' || !$ids) { + return true; + } + $current = function_exists('is_singular') && is_singular() && function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; + $matched = in_array($current, $ids, true); + return $type === 'show' ? $matched : !$matched; + } + + private static function title(array $instance): string { + $title = trim((string) ($instance['title'] ?? '')); + if ($title === '') { + return ''; + } + $safe_title = esc_html($title); + $highlight = trim((string) ($instance['title_highlight'] ?? '')); + if ($highlight !== '') { + $safe_highlight = esc_html($highlight); + $safe_title = str_replace($safe_highlight, '' . $safe_highlight . '', $safe_title); + } + $html = '

' . nl2br($safe_title) . '

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

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

'; + } + $html .= '
'; + } + + private static function style(array $instance): string { + $properties = []; + foreach (['layout_pin_pc' => 'space', 'layout_pin_m' => 'space-m'] as $key => $prefix) { + foreach (['top', 'right', 'bottom', 'left'] as $edge) { + $value = $instance[$key][$edge] ?? ''; + if ($value !== '' && is_numeric($value)) { + $properties[] = '--aksrt-widget-' . $prefix . '-' . $edge . ':' . (float) $value . 'px'; + } + } + } + foreach (['light', 'dark'] as $mode) { + $background = (array) ($instance['layout_bg'][$mode] ?? []); + if (!empty($background['color'])) { + $properties[] = '--aksrt-widget-bg-' . $mode . ':' . (string) $background['color']; + } + if (!empty($background['image'])) { + $properties[] = '--aksrt-widget-bg-image-' . $mode . ':url(' . esc_url_raw((string) $background['image']) . ')'; + } + } + return implode(';', $properties); + } +} diff --git a/src/WidgetSystem/PostQuery.php b/src/WidgetSystem/PostQuery.php new file mode 100644 index 00000000..633cbcd7 --- /dev/null +++ b/src/WidgetSystem/PostQuery.php @@ -0,0 +1,94 @@ + */ + public static function arguments(array $instance, int $page = 1, array $segment = []): array { + $config = array_merge($instance, $segment); + $count = max(1, min(50, (int) ($config['count'] ?? 8))); + $args = [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $count, + 'paged' => max(1, $page), + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + ]; + + $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'] ?? '')); + if ($tags) { + $args['tag__in'] = $tags; + } + + $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]]; + } + self::apply_order($args, (string) ($config['orderby'] ?? 'date'), (string) ($config['order'] ?? 'DESC')); + self::apply_pay_type($args, (string) ($config['pay_type'] ?? '')); + return $args; + } + + public static function segment(array $instance, string $segment): array { + $tabs = array_values((array) ($instance['tabs'] ?? [])); + if (!$tabs) { + return []; + } + $index = ctype_digit($segment) ? (int) $segment : 0; + return isset($tabs[$index]) && is_array($tabs[$index]) ? $tabs[$index] : $tabs[0]; + } + + private static function apply_order(array &$args, string $orderby, string $order): void { + $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', + 'downloads' => class_exists('AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY : '_aksrt_res_download_count', + ]; + if (isset($meta[$orderby])) { + $args['meta_key'] = $meta[$orderby]; + $args['orderby'] = 'meta_value_num'; + return; + } + if ($orderby === 'comments') { + $args['orderby'] = 'comment_count'; + } elseif (in_array($orderby, ['date', 'modified', 'rand'], true)) { + $args['orderby'] = $orderby; + } else { + $args['orderby'] = 'date'; + } + } + + private static function apply_pay_type(array &$args, string $pay_type): void { + if (!in_array($pay_type, self::PAY_TYPES, true)) { + 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'] = [[ + 'relation' => 'OR', + ['key' => $type_key, 'value' => $pay_type, 'compare' => '='], + ['key' => $meta_key, 'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"', 'compare' => 'LIKE'], + ]]; + } +} diff --git a/src/WidgetSystem/PostRenderer.php b/src/WidgetSystem/PostRenderer.php new file mode 100644 index 00000000..a09d5cf5 --- /dev/null +++ b/src/WidgetSystem/PostRenderer.php @@ -0,0 +1,95 @@ +max_num_pages); + $total = (int) $query->found_posts; + return [ + 'html' => $html, + 'paginate_html' => self::pagination($instance, $page, $max_pages), + 'has_more' => $page < $max_pages, + 'page' => $page, + 'max_pages' => $max_pages, + 'total' => $total, + ]; + } + + public static function items(\WP_Query $query, array $instance, string $slug, int $page = 1): string { + if (!$query->have_posts()) { + return RendererHelpers::empty_state(__('暂无文章', 'aksrt')); + } + $style = (string) ($instance['style'] ?? 'card'); + $mode = in_array($style, ['list', 'card', 'mini'], true) ? $style : 'card'; + $config = [ + 'mode' => $mode, + 'context' => 'widget', + 'columns' => $mode === 'card' ? 3 : 1, + 'show_thumb' => $slug !== 'hot_posts' || !empty($instance['show_thumbnail']), + 'show_meta' => $mode !== 'mini', + 'show_excerpt' => $mode !== 'mini', + 'show_author' => false, + 'show_comments' => true, + 'show_likes' => true, + 'show_tags' => false, + 'show_badges' => true, + 'show_number' => $slug === 'hot_posts', + 'number_offset' => ($page - 1) * max(1, (int) ($instance['count'] ?? 8)), + 'wrapper_class' => 'aksrt-widget-posts aksrt-widget-posts--' . $slug, + ]; + if ($slug === 'oneline_posts') { + return self::oneline($query, $config, !empty($instance['autoplay'])); + } + if (function_exists('aksrt_render_post_list')) { + return aksrt_render_post_list($query, $config, false); + } + + $html = '
'; + while ($query->have_posts()) { + $query->the_post(); + $html .= ''; + } + wp_reset_postdata(); + return $html . '
'; + } + + private static function oneline(\WP_Query $query, array $config, bool $autoplay): string { + $slides = ''; + while ($query->have_posts()) { + $query->the_post(); + $item = function_exists('aksrt_render_post') + ? aksrt_render_post(array_merge($config, ['skip_wrapper' => true])) + : '' . esc_html(get_the_title()) . ''; + $slides .= '
' . $item . '
'; + } + wp_reset_postdata(); + return '
' . $slides . '
'; + } + + private static function pagination(array $instance, int $page, int $max_pages): string { + $mode = (string) ($instance['paginate'] ?? 'none'); + if ($max_pages <= 1 || $mode === 'none') { + return ''; + } + if ($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'); + return ''; + } +} diff --git a/src/WidgetSystem/RendererHelpers.php b/src/WidgetSystem/RendererHelpers.php new file mode 100644 index 00000000..d51b3623 --- /dev/null +++ b/src/WidgetSystem/RendererHelpers.php @@ -0,0 +1,67 @@ + $url, 'text' => $text, 'target' => $target]; + } + + public static function link_attrs(array $link): string { + $link = self::link($link); + return $link['target'] === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; + } + + public static function image(string $url, string $alt = '', string $class = ''): string { + if ($url === '') { + return ''; + } + return ''; + } + + public static function lines(string $value): array { + $lines = preg_split('/\r\n|\r|\n/', $value) ?: []; + return array_values(array_filter(array_map('trim', $lines), static fn(string $line): bool => $line !== '')); + } + + public static function empty_state(string $message): string { + if (function_exists('aksrt_get_widget_null')) { + return aksrt_get_widget_null($message); + } + return '
' . esc_html($message) . '
'; + } + + public static function card_media(array $item, string $class = ''): string { + $title = (string) ($item['title'] ?? ''); + $link = self::link((array) ($item['link'] ?? [])); + $media = ''; + if (!empty($item['image_dark'])) { + $media .= self::image((string) $item['image_dark'], $title, 'aksrt-media-card__image aksrt-media-card__image--dark'); + } + $media .= self::image((string) ($item['image'] ?? ''), $title, 'aksrt-media-card__image'); + if (!empty($item['video'])) { + $media .= ''; + } + $body = '' . esc_html($title) . ''; + if (!empty($item['description'])) { + $body .= '' . esc_html((string) $item['description']) . ''; + } + $body .= ''; + $tag = $link['url'] !== '' ? 'a' : 'div'; + $attrs = $link['url'] !== '' ? ' href="' . $link['url'] . '"' . self::link_attrs($link) : ''; + return '<' . $tag . ' class="aksrt-media-card ' . esc_attr($class) . '"' . $attrs . '>' . $media . '' . $body . ''; + } + + public static function ids(string $value): array { + return array_values(array_filter(array_map('absint', preg_split('/\s*,\s*/', $value) ?: []))); + } +} diff --git a/src/WidgetSystem/RendererRegistry.php b/src/WidgetSystem/RendererRegistry.php new file mode 100644 index 00000000..5bf4739b --- /dev/null +++ b/src/WidgetSystem/RendererRegistry.php @@ -0,0 +1,54 @@ + */ + public static function all(): array { + return [ + 'search' => GeneralRenderer::class, 'posts_toc' => GeneralRenderer::class, + 'quote' => GeneralRenderer::class, 'icon_cards' => GeneralRenderer::class, + 'video' => GeneralRenderer::class, 'embed' => GeneralRenderer::class, + 'media_cards' => GeneralRenderer::class, 'icon_cover_cards' => GeneralRenderer::class, + 'partners' => GeneralRenderer::class, 'combo_info' => GeneralRenderer::class, + 'text_title' => GeneralRenderer::class, 'buttons' => GeneralRenderer::class, + 'layout_gap' => GeneralRenderer::class, 'tag_cloud' => GeneralRenderer::class, + 'recent_comments' => GeneralRenderer::class, 'links' => GeneralRenderer::class, + 'notice' => GeneralRenderer::class, + 'term_cards' => ContentRenderer::class, 'term_aggregation' => ContentRenderer::class, + 'hot_posts' => ContentRenderer::class, 'post_pay' => ContentRenderer::class, + 'posts' => ContentRenderer::class, 'tab_posts' => ContentRenderer::class, + 'oneline_posts' => ContentRenderer::class, + 'pricing_plans' => KnowledgeProductRenderer::class, 'reviews' => KnowledgeProductRenderer::class, + 'faq' => KnowledgeProductRenderer::class, 'stats' => KnowledgeProductRenderer::class, + 'highlights' => KnowledgeProductRenderer::class, 'feature_intro' => KnowledgeProductRenderer::class, + 'tilt_cases' => KnowledgeProductRenderer::class, + 'slider' => SliderRenderer::class, 'slider_media_combo' => SliderRenderer::class, + 'user_card' => UserRenderer::class, 'author_card' => UserRenderer::class, + 'user_ranking' => UserRenderer::class, 'user_list' => UserRenderer::class, + ]; + } + + public static function render(string $slug, array $instance, array $context = []): string { + $renderer = self::all()[$slug] ?? null; + if ($renderer === null || !is_callable([$renderer, 'render'])) { + return ''; + } + return (string) $renderer::render($slug, $instance, $context); + } + + public static function supports_items(string $slug): bool { + return in_array($slug, ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'], true); + } +} diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php new file mode 100644 index 00000000..96f2f083 --- /dev/null +++ b/src/WidgetSystem/Renderers/ContentRenderer.php @@ -0,0 +1,136 @@ + (string) $instance['taxonomy'], + 'number' => (int) $instance['count'], + 'hide_empty' => true, + 'orderby' => 'count', + 'order' => 'DESC', + ]; + $ids = RendererHelpers::ids((string) $instance['term_ids']); + if ($ids) { + $args['include'] = $ids; + $args['orderby'] = 'include'; + } + $terms = get_terms($args); + return is_wp_error($terms) ? [] : (array) $terms; + } + + private static function term_cards(array $instance): string { + $terms = self::terms($instance); + if (!$terms) { + return RendererHelpers::empty_state(__('暂无分类', 'aksrt')); + } + $html = '
'; + foreach ($terms as $term) { + $link = get_term_link($term); + if (is_wp_error($link)) { + continue; + } + $image = (string) get_term_meta((int) $term->term_id, 'aksrt_cover_url', true); + if ($image === '') { + $image = (string) get_term_meta((int) $term->term_id, 'cover_url', true); + } + $html .= '' . RendererHelpers::image($image, $term->name) . '' . esc_html($term->name) . '' . esc_html(wp_trim_words((string) $term->description, 18)) . '' . sprintf(esc_html__('%s 篇内容', 'aksrt'), number_format_i18n((int) $term->count)) . ''; + } + return $html . '
'; + } + + private static function term_aggregation(array $instance): string { + $terms = self::terms($instance); + if (!$terms) { + return RendererHelpers::empty_state(__('暂无聚合内容', 'aksrt')); + } + $taxonomy = (string) $instance['taxonomy']; + $term_ids = array_map(static fn($term): int => (int) $term->term_id, $terms); + $query = new \WP_Query([ + 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => min(100, count($terms) * 6), + 'ignore_sticky_posts' => true, 'no_found_rows' => true, + 'tax_query' => [['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_ids]], + ]); + $by_term = []; + foreach ($query->posts as $post) { + foreach (wp_get_post_terms((int) $post->ID, $taxonomy, ['fields' => 'ids']) as $term_id) { + if (in_array((int) $term_id, $term_ids, true) && count($by_term[$term_id] ?? []) < 4) { + $by_term[$term_id][] = $post; + } + } + } + $html = '
'; + foreach ($terms as $term) { + $link = get_term_link($term); + if (is_wp_error($link)) { + continue; + } + $html .= '
' . esc_html($term->name) . '' . esc_html__('查看全部', 'aksrt') . '
'; + } + return $html . '
'; + } + + private static function posts(string $slug, array $instance, array $context): string { + $widget_id = (string) ($context['widget_id'] ?? ''); + if (($instance['load_mode'] ?? 'sync') === 'ajax') { + return '
'; + } + $payload = PostRenderer::payload($instance, $slug, 1); + return '
' . $payload['html'] . $payload['paginate_html'] . '
'; + } + + private static function tab_posts(array $instance, array $context): string { + $tabs = array_values((array) $instance['tabs']); + if (!$tabs) { + return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt')); + } + $buttons = ''; + foreach ($tabs as $index => $tab) { + $buttons .= ''; + } + $payload = PostRenderer::payload($instance, 'tab_posts', 1, '0'); + return '
' . $buttons . '
' . $payload['html'] . $payload['paginate_html'] . '
'; + } + + private static function post_pay(array $instance): string { + $post = get_post(); + if (!$post instanceof \WP_Post || !function_exists('aksrt_get_pay_meta')) { + return ''; + } + $meta = aksrt_get_pay_meta($post); + if (($meta['pay_type'] ?? 'no') === 'no') { + return RendererHelpers::empty_state(__('当前文章未配置付费内容', 'aksrt')); + } + if (function_exists('aksrt_has_paid_access') && aksrt_has_paid_access($post)) { + $html = '
' . esc_html__('内容已解锁', 'aksrt') . '

' . esc_html__('你已拥有当前知识内容的访问权限。', 'aksrt') . '

'; + $html .= function_exists('aksrt_get_pay_attributes_html') ? aksrt_get_pay_attributes_html($post, 'aksrt-post-pay__attrs') : ''; + $html .= function_exists('aksrt_get_demo_link_html') ? aksrt_get_demo_link_html($post, 'aksrt-widget-button') : ''; + return $html . '
'; + } + return function_exists('aksrt_get_paywall_html') ? aksrt_get_paywall_html($post, true) : RendererHelpers::empty_state(__('请登录或购买后查看', 'aksrt')); + } +} diff --git a/src/WidgetSystem/Renderers/GeneralRenderer.php b/src/WidgetSystem/Renderers/GeneralRenderer.php new file mode 100644 index 00000000..8f53bd8c --- /dev/null +++ b/src/WidgetSystem/Renderers/GeneralRenderer.php @@ -0,0 +1,196 @@ +'; + } + + private static function render_posts_toc(array $i): string { + if (!is_singular('post')) { + return ''; + } + return ''; + } + + private static function render_quote(array $i): string { + $quotes = RendererHelpers::lines((string) $i['quotes']); + $text = $quotes ? $quotes[array_rand($quotes)] : __('保持好奇,持续创造。', 'aksrt'); + return '
' . esc_html($text) . '
'; + } + + private static function render_icon_cards(array $i): string { + return self::icon_cards((array) $i['cards'], false, (int) $i['pc_row'], (int) $i['m_row']); + } + + private static function render_icon_cover_cards(array $i): string { + return self::icon_cards((array) $i['cards'], true, (int) $i['pc_row'], (int) $i['m_row']); + } + + private static function icon_cards(array $cards, bool $cover, int $pc, int $mobile): string { + $html = '
'; + foreach ($cards as $card) { + if (!is_array($card)) { + continue; + } + $args = ['icon' => $card['icon'] ?? '', 'customize_icon' => $card['custom_icon'] ?? '', 'icon_image' => $card['custom_icon'] ?? '', 'icon_color' => $card['color'] ?? '', 'icon_class' => $card['color'] ?? '', 'title' => $card['title'] ?? '', 'desc' => $card['description'] ?? '', 'link' => $card['link'] ?? []]; + if ($cover && function_exists('aksrt_icon_cover_card')) { + $html .= aksrt_icon_cover_card($args); + } elseif (!$cover && function_exists('aksrt_icon_card')) { + $html .= aksrt_icon_card($args); + } else { + $link = RendererHelpers::link((array) ($card['link'] ?? [])); + $tag = $link['url'] ? 'a' : 'div'; + $html .= '<' . $tag . ' class="aksrt-widget-icon-card ' . esc_attr((string) ($card['color'] ?? '')) . '"' . ($link['url'] ? ' href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) : '') . '>' . esc_html((string) ($card['title'] ?? '')) . '' . esc_html((string) ($card['description'] ?? '')) . ''; + } + } + return $html . '
'; + } + + private static function render_video(array $i): string { + $url = esc_url((string) $i['url']); + if ($url === '') { + return RendererHelpers::empty_state(__('请配置视频地址', 'aksrt')); + } + $ratio = (string) $i['ratio']; + return '
'; + } + + private static function render_embed(array $i): string { + if (empty($i['url'])) { + return RendererHelpers::empty_state(__('请配置嵌入地址', 'aksrt')); + } + return '
'; + } + + private static function render_media_cards(array $i): string { + return self::media_grid((array) $i['cards']); + } + + private static function media_grid(array $items, string $class = ''): string { + $html = '
'; + foreach ($items as $item) { + if (is_array($item)) { + $html .= RendererHelpers::card_media($item); + } + } + return $html . '
'; + } + + private static function render_partners(array $i): string { + $items = ''; + foreach ((array) $i['items'] as $item) { + if (is_array($item)) { + $items .= RendererHelpers::card_media($item, 'aksrt-partner'); + } + } + return '
' . $items . $items . '
'; + } + + private static function render_combo_info(array $i): string { + $html = '
'; + foreach ((array) $i['items'] as $item) { + if (!is_array($item)) { + continue; + } + $tags = ''; + foreach (preg_split('/\s*[,,]\s*/', (string) ($item['tags'] ?? '')) ?: [] as $tag) { + if ($tag !== '') { + $tags .= '' . esc_html($tag) . ''; + } + } + $html .= '
' . RendererHelpers::card_media($item) . ($tags ? '
' . $tags . '
' : '') . '
'; + } + return $html . '
'; + } + + private static function render_text_title(array $i): string { + return '

' . nl2br(esc_html((string) $i['heading'])) . '

' . nl2br(esc_html((string) $i['description'])) . '

'; + } + + private static function render_buttons(array $i): string { + $html = '
'; + foreach ((array) $i['buttons'] as $button) { + $link = RendererHelpers::link((array) ($button['link'] ?? [])); + if ($link['url'] === '') { + continue; + } + $html .= '' . (!empty($button['icon']) ? '' : '') . '' . esc_html((string) ($button['text'] ?? $link['text'])) . ''; + } + return $html . '
'; + } + + private static function render_layout_gap(array $i): string { + return ''; + } + + private static function render_tag_cloud(array $i): string { + $args = ['taxonomy' => (string) $i['taxonomy'], 'number' => (int) $i['number'], 'hide_empty' => true]; + if ($i['orderby'] !== 'rand') { + $args['orderby'] = (string) $i['orderby']; + $args['order'] = 'DESC'; + } + $terms = get_terms($args); + if (is_wp_error($terms) || !$terms) { + return RendererHelpers::empty_state(__('暂无标签', 'aksrt')); + } + if ($i['orderby'] === 'rand') { + shuffle($terms); + } + $html = '
'; + foreach (array_slice($terms, 0, (int) $i['number']) as $term) { + $url = get_term_link($term); + if (!is_wp_error($url)) { + $html .= '' . esc_html($term->name) . (!empty($i['show_count']) ? '' . (int) $term->count . '' : '') . ''; + } + } + return $html . '
'; + } + + private static function render_recent_comments(array $i): string { + $comments = get_comments(['status' => 'approve', 'number' => (int) $i['count'], 'user__not_in' => RendererHelpers::ids((string) $i['exclude_users']), 'post__not_in' => RendererHelpers::ids((string) $i['exclude_posts'])]); + if (!$comments) { + return RendererHelpers::empty_state(__('暂无评论', 'aksrt')); + } + $html = ''; + } + + private static function render_links(array $i): string { + $html = ''; + } + + private static function render_notice(array $i): string { + $slides = ''; + foreach ((array) $i['items'] as $item) { + $link = RendererHelpers::link((array) ($item['link'] ?? [])); + $content = '' . esc_html((string) ($item['title'] ?? '')) . '' . esc_html((string) ($item['description'] ?? '')) . ''; + $slides .= '
' . ($link['url'] ? '' . $content . '' : $content) . '
'; + } + return '
' . $slides . '
'; + } +} diff --git a/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php b/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php new file mode 100644 index 00000000..3bd2f181 --- /dev/null +++ b/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php @@ -0,0 +1,119 @@ +'; + foreach ((array) $i['plans'] as $plan) { + if (!is_array($plan)) { + continue; + } + $url = ''; + $label = __('立即了解', 'aksrt'); + if (($plan['cta_type'] ?? 'link') === 'post' && !empty($plan['post_id'])) { + $url = (string) get_permalink((int) $plan['post_id']); + $label = __('查看付费内容', 'aksrt'); + } elseif (($plan['cta_type'] ?? 'link') === 'vip') { + $url = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('vip') : home_url('/'); + $url = add_query_arg('level', (int) ($plan['vip_level'] ?? 1), $url); + $label = __('开通会员', 'aksrt'); + } else { + $link = RendererHelpers::link((array) ($plan['link'] ?? [])); + $url = $link['url']; + $label = $link['text'] ?: $label; + } + $html .= '
'; + if (!empty($plan['featured'])) { + $html .= '' . esc_html__('推荐', 'aksrt') . ''; + } + $html .= '

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

' . esc_html((string) ($plan['price'] ?? '')) . '

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

    '; + foreach (RendererHelpers::lines((string) ($plan['features'] ?? '')) as $feature) { + $html .= '
  • ' . esc_html($feature) . '
  • '; + } + $html .= '
' . ($url !== '' ? '' . esc_html($label) . '' : '') . '
'; + } + return $html . ''; + } + + private static function reviews(array $i): string { + $slides = ''; + foreach ((array) $i['items'] as $item) { + if (!is_array($item)) { + continue; + } + $rating = max(1, min(5, (int) ($item['rating'] ?? 5))); + $slides .= '
' . str_repeat('', $rating) . '
' . esc_html((string) ($item['description'] ?? '')) . '
' . RendererHelpers::image((string) ($item['image'] ?? ''), '', 'aksrt-review-card__avatar') . '' . esc_html((string) ($item['title'] ?? '')) . '' . esc_html((string) ($item['value'] ?? '')) . '
'; + } + return '
' . $slides . '
'; + } + + private static function faq(array $i): string { + $html = '
'; + if (!empty($i['search'])) { + $html .= ''; + } + $html .= '
'; + foreach ((array) $i['items'] as $index => $item) { + $id = 'aksrt-faq-' . $index . '-' . wp_rand(1000, 9999); + $html .= '
'; + } + return $html . '
'; + } + + private static function stats(array $i): string { + $html = '
'; + foreach ((array) $i['items'] as $item) { + $html .= '
' . esc_html((string) ($item['value'] ?? '')) . '

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

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

'; + } + return $html . '
'; + } + + private static function highlights(array $i): string { + $html = '
'; + foreach ((array) $i['items'] as $index => $item) { + $html .= RendererHelpers::card_media((array) $item, 'aksrt-highlight aksrt-highlight--' . (($index % 5 === 0) ? 'large' : 'normal')); + } + return $html . '
'; + } + + private static function feature_intro(array $i): string { + $tabs = $panels = ''; + foreach ((array) $i['items'] as $index => $item) { + $tabs .= ''; + $features = ''; + foreach (RendererHelpers::lines((string) ($item['features'] ?? '')) as $feature) { + $features .= '
  • ' . esc_html($feature) . '
  • '; + } + $panels .= '
    ' . RendererHelpers::card_media((array) $item) . '

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

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

      ' . $features . '
    '; + } + return '
    ' . $tabs . '
    ' . $panels . '
    '; + } + + private static function tilt(array $i): string { + $html = '
    '; + foreach ((array) $i['items'] as $item) { + $html .= '
    ' . RendererHelpers::card_media((array) $item) . '
    '; + } + return $html . '
    '; + } +} diff --git a/src/WidgetSystem/Renderers/SliderRenderer.php b/src/WidgetSystem/Renderers/SliderRenderer.php new file mode 100644 index 00000000..e9aaf0a5 --- /dev/null +++ b/src/WidgetSystem/Renderers/SliderRenderer.php @@ -0,0 +1,55 @@ +' + : RendererHelpers::image((string) $slide['background'], (string) ($slide['title'] ?? ''), 'swiper-lazy'); + $layers = ''; + foreach ((array) ($slide['layers'] ?? []) as $layer) { + if (!empty($layer['image'])) { + $layers .= ''; + } + } + $copy = '

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

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

    '; + $body = $media . $layers . $copy; + if ($link['url']) { + $body = '' . $body . ''; + } + $slides .= '
    ' . $body . '
    '; + } + if ($slides === '') { + return RendererHelpers::empty_state(__('请配置幻灯片', 'aksrt')); + } + $slider = '
    ' . $slides . '
    '; + if (!empty($instance['button'])) { + $slider .= ''; + } + 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 . '
    '; + } +} diff --git a/src/WidgetSystem/Renderers/UserRenderer.php b/src/WidgetSystem/Renderers/UserRenderer.php new file mode 100644 index 00000000..b59143b6 --- /dev/null +++ b/src/WidgetSystem/Renderers/UserRenderer.php @@ -0,0 +1,164 @@ +render_social_buttons(__('社交账号登录', 'aksrt')); + } elseif (is_callable([$oauth, 'get_login_buttons'])) { + $oauth_html = (string) $oauth->get_login_buttons(); + } + } + return '
    ' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html(0, 88) : get_avatar(0, 88)) . '

    ' . esc_html__('Hi,请登录', 'aksrt') . '

    ' . esc_html__('登录后管理课程、会员、订单与创作内容。', 'aksrt') . '

    ' . ($oauth_html !== '' ? '
    ' . $oauth_html . '
    ' : '') . '
    '; + } + $user = get_userdata($user_id); + return $user instanceof \WP_User ? self::profile_card($user, $i, true) : ''; + } + + private static function author_card(array $i): string { + $post = get_post(); + if (!$post instanceof \WP_Post) { + return ''; + } + $user = get_userdata((int) $post->post_author); + return $user instanceof \WP_User ? self::profile_card($user, $i, false) : ''; + } + + private static function profile_card(\WP_User $user, array $i, bool $self_card): string { + $user_id = (int) $user->ID; + $home = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : get_author_posts_url($user_id); + $cover = (string) get_user_meta($user_id, 'aksrt_background_image', true); + $followers = class_exists('AKSRT\\User\\Follow') ? \AKSRT\User\Follow::get_instance()->get_follower_count($user_id) : 0; + $following = class_exists('AKSRT\\User\\Follow') ? \AKSRT\User\Follow::get_instance()->get_following_count($user_id) : 0; + $html = '
    '; + if (!empty($i['show_cover'])) { + $html .= '
    '; + } + $html .= '
    ' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($user_id, 96) : get_avatar($user_id, 96)) . '

    ' . esc_html($user->display_name) . '' . (function_exists('aksrt_render_vip_badge') ? aksrt_render_vip_badge($user_id) : '') . '

    '; + if (function_exists('aksrt_render_user_identity_chips')) { + $html .= '
    ' . aksrt_render_user_identity_chips($user_id, 'widget') . '
    '; + } + $html .= '

    ' . esc_html(wp_trim_words((string) $user->description, 24)) . '

    ' . number_format_i18n(count_user_posts($user_id, 'post', true)) . '' . esc_html__('文章', 'aksrt') . '' . number_format_i18n($followers) . '' . esc_html__('粉丝', 'aksrt') . '' . number_format_i18n($following) . '' . esc_html__('关注', 'aksrt') . '
    '; + if (!empty($i['show_buttons'])) { + $html .= '
    ' . self::action((string) $i['primary_action'], $user, true) . self::action((string) $i['secondary_action'], $user, false) . '
    '; + } + if (!$self_card && get_current_user_id() > 0 && get_current_user_id() !== $user_id) { + $active = class_exists('AKSRT\\User\\Follow') && \AKSRT\User\Follow::get_instance()->is_following(get_current_user_id(), $user_id); + $html .= ''; + } + $html .= '
    '; + if (!empty($i['show_posts']) && function_exists('aksrt_render_user_card_posts')) { + $html .= '
    ' . aksrt_render_user_card_posts(['user_id' => $user_id, 'count' => 4, 'post_style' => 'mini', 'show_empty' => false]) . '
    '; + } + return $html . '
    '; + } + + private static function action(string $action, \WP_User $user, bool $primary): string { + $labels = ['home' => __('个人主页', 'aksrt'), 'submit' => __('发布文章', 'aksrt'), 'vip' => __('会员中心', 'aksrt'), 'orders' => __('订单中心', 'aksrt'), 'recharge' => __('余额充值', 'aksrt'), 'signin' => __('签到', 'aksrt')]; + if ($action === 'signin' && function_exists('aksrt_header_user_checkin_btn') && get_current_user_id() === (int) $user->ID) { + return '' . aksrt_header_user_checkin_btn((int) $user->ID, true) . ''; + } + if ($action === 'home') { + $url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : get_author_posts_url((int) $user->ID); + } elseif ($action === 'submit') { + $url = function_exists('aksrt_get_new_post_url') ? aksrt_get_new_post_url() : home_url('/submit/'); + } else { + $tab = $action === 'signin' ? 'credits' : $action; + $url = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url($tab) : home_url('/user/'); + } + if ($url === '') { + return ''; + } + return '' . esc_html($labels[$action] ?? $labels['home']) . ''; + } + + private static function ranking(array $i): string { + $users = get_users(['number' => 200, 'orderby' => 'registered', 'order' => 'DESC']); + $metric = (string) $i['orderby']; + usort($users, static fn(\WP_User $a, \WP_User $b): int => self::metric($b, $metric) <=> self::metric($a, $metric)); + $users = array_slice($users, 0, (int) $i['number']); + if (!$users) { + return RendererHelpers::empty_state(__('暂无用户数据', 'aksrt')); + } + $html = '
      '; + foreach ($users as $index => $user) { + $html .= '
    1. ' . ($index + 1) . '' . self::user_tile($user, self::metric($user, $metric), self::metric_label($metric)) . '
    2. '; + } + return $html . '
    '; + } + + private static function user_list(array $i): string { + $args = ['number' => 200]; + if (!empty($i['include'])) { + $args['include'] = RendererHelpers::ids((string) $i['include']); + } + if (!empty($i['exclude'])) { + $args['exclude'] = RendererHelpers::ids((string) $i['exclude']); + } + $users = get_users($args); + $orderby = (string) $i['orderby']; + usort($users, static function (\WP_User $a, \WP_User $b) use ($orderby): int { + if ($orderby === 'display_name') { + return strcasecmp($a->display_name, $b->display_name); + } + if ($orderby === 'registered') { + return strcmp($b->user_registered, $a->user_registered); + } + return self::metric($b, $orderby) <=> self::metric($a, $orderby); + }); + $html = '
    '; + foreach (array_slice($users, 0, (int) $i['number']) as $user) { + $html .= '
    ' . self::user_tile($user, self::metric($user, $orderby), self::metric_label($orderby)) . '
    '; + } + return $html . '
    '; + } + + private static function user_tile(\WP_User $user, int $value, string $label): string { + $url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : get_author_posts_url((int) $user->ID); + return '' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($user->ID, 64) : get_avatar($user->ID, 64)) . '' . esc_html($user->display_name) . '' . esc_html($label) . ' ' . number_format_i18n($value) . ''; + } + + private static function metric(\WP_User $user, string $metric): int { + $id = (int) $user->ID; + if ($metric === 'points') { + return function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($id) : 0; + } + if ($metric === 'followers') { + return class_exists('AKSRT\\User\\Follow') ? (int) \AKSRT\User\Follow::get_instance()->get_follower_count($id) : 0; + } + if ($metric === 'post_count') { + return (int) count_user_posts($id, 'post', true); + } + $key = $metric === 'checkin_continuous_day' ? 'aksrt_checkin_continuous_day' : 'aksrt_checkin_all_day'; + return (int) get_user_meta($id, $key, true); + } + + private static function metric_label(string $metric): string { + $labels = ['points' => __('积分', 'aksrt'), 'followers' => __('粉丝', 'aksrt'), 'post_count' => __('文章', 'aksrt'), 'registered' => __('新加入', 'aksrt'), 'display_name' => __('用户', 'aksrt'), 'checkin_all_day' => __('累计签到', 'aksrt'), 'checkin_continuous_day' => __('连续签到', 'aksrt')]; + return $labels[$metric] ?? $labels['points']; + } +} diff --git a/src/WidgetSystem/RestController.php b/src/WidgetSystem/RestController.php new file mode 100644 index 00000000..e2414e1b --- /dev/null +++ b/src/WidgetSystem/RestController.php @@ -0,0 +1,106 @@ +namespace = 'aksrt/v1'; + $this->rest_base = 'widgets'; + } + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P[a-z][a-z0-9_]*)/items', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'get_items'], + 'permission_callback' => '__return_true', + 'args' => [ + 'widget' => ['type' => 'string', 'required' => true, 'pattern' => '^[a-z][a-z0-9_]*$', 'validate_callback' => [$this, 'validate_widget']], + 'instance' => ['type' => 'string', 'required' => true, 'pattern' => '^aksrt_widget_[a-z0-9_]+-[1-9][0-9]*$'], + 'page' => ['type' => 'integer', 'default' => 1, 'minimum' => 1, 'maximum' => 1000], + 'segment' => ['type' => 'string', 'default' => '', 'pattern' => '^[0-9]{0,2}$'], + ], + ]); + register_rest_route($this->namespace, '/' . $this->rest_base . '/quote', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'get_quote'], + 'permission_callback' => '__return_true', + 'args' => [], + ]); + } + + public function validate_widget($value): bool { + return is_string($value) && WidgetRegistry::get($value) !== null; + } + + /** @return \WP_REST_Response|\WP_Error */ + public function get_items(\WP_REST_Request $request) { + $unknown = array_diff(array_keys($request->get_params()), ['widget', 'instance', 'page', 'segment']); + if ($unknown) { + return new \WP_Error('aksrt_widget_unknown_parameter', __('请求包含不支持的参数。', 'aksrt'), ['status' => 400, 'parameters' => array_values($unknown)]); + } + $slug = (string) $request->get_param('widget'); + if (!RendererRegistry::supports_items($slug)) { + return new \WP_Error('aksrt_widget_items_unsupported', __('该小工具不提供动态列表。', 'aksrt'), ['status' => 404]); + } + $instance_id = (string) $request->get_param('instance'); + $instance = $this->load_instance($slug, $instance_id); + if (is_wp_error($instance)) { + return $instance; + } + $page = max(1, (int) $request->get_param('page')); + $segment = (string) $request->get_param('segment'); + $payload = PostRenderer::payload($instance, $slug, $page, $segment); + return rest_ensure_response(['success' => true, 'code' => 'ok'] + $payload); + } + + /** @return \WP_REST_Response|\WP_Error */ + public function get_quote(\WP_REST_Request $request) { + if ($request->get_params()) { + return new \WP_Error('aksrt_widget_unknown_parameter', __('一言接口不接受查询参数。', 'aksrt'), ['status' => 400]); + } + $cached = get_transient('aksrt_widget_quote'); + if (is_array($cached) && !empty($cached['text'])) { + return rest_ensure_response($cached); + } + $fallbacks = [__('保持好奇,持续创造。', 'aksrt'), __('知识因为分享而更有价值。', 'aksrt'), __('把复杂的事,做得清楚而简单。', 'aksrt')]; + $data = ['text' => $fallbacks[array_rand($fallbacks)], 'author' => 'AKSRT', 'source' => 'local']; + $response = wp_remote_get('https://v1.hitokoto.cn/?encode=json', ['timeout' => 3, 'redirection' => 2, 'user-agent' => 'AKSRT Widget/1.0']); + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { + $remote = json_decode((string) wp_remote_retrieve_body($response), true); + if (is_array($remote) && !empty($remote['hitokoto'])) { + $data = ['text' => sanitize_text_field((string) $remote['hitokoto']), 'author' => sanitize_text_field((string) ($remote['from_who'] ?? $remote['from'] ?? '')), 'source' => 'hitokoto']; + } + } + set_transient('aksrt_widget_quote', $data, 10 * MINUTE_IN_SECONDS); + return rest_ensure_response($data); + } + + /** @return array|\WP_Error */ + private function load_instance(string $slug, string $instance_id) { + $definition = WidgetRegistry::get($slug); + if ($definition === null || !preg_match('/^' . preg_quote($definition->id_base(), '/') . '-([1-9][0-9]*)$/', $instance_id, $matches)) { + return new \WP_Error('aksrt_widget_invalid_instance', __('小工具实例无效。', 'aksrt'), ['status' => 400]); + } + $sidebars = wp_get_sidebars_widgets(); + $registered = false; + foreach ($sidebars as $widgets) { + if (is_array($widgets) && in_array($instance_id, $widgets, true)) { + $registered = true; + break; + } + } + if (!$registered) { + return new \WP_Error('aksrt_widget_instance_not_registered', __('小工具实例未挂载。', 'aksrt'), ['status' => 404]); + } + $widget = new SchemaWidget($definition); + $settings = $widget->get_settings(); + $number = (int) $matches[1]; + if (!is_array($settings) || !isset($settings[$number]) || !is_array($settings[$number])) { + return new \WP_Error('aksrt_widget_instance_not_found', __('未找到小工具配置。', 'aksrt'), ['status' => 404]); + } + return $definition->normalize($settings[$number]); + } +} diff --git a/src/WidgetSystem/SchemaWidget.php b/src/WidgetSystem/SchemaWidget.php new file mode 100644 index 00000000..6d66391e --- /dev/null +++ b/src/WidgetSystem/SchemaWidget.php @@ -0,0 +1,73 @@ +definition = $definition; + parent::__construct( + $definition->id_base(), + $definition->name(), + [ + 'classname' => 'aksrt-schema-widget aksrt-schema-widget--' . $definition->slug(), + 'description' => $definition->description(), + 'customize_selective_refresh' => true, + ], + ['width' => 520] + ); + } + + public function definition(): WidgetDefinition { + return $this->definition; + } + + public function widget($args, $instance): void { + $renderer = 'AKSRT\\WidgetSystem\\FrontendRenderer'; + if (class_exists($renderer) && is_callable([$renderer, 'render'])) { + $renderer::render($this->definition, (array) $args, $this->definition->normalize((array) $instance), $this); + } + } + + public function form($instance): void { + $instance = array_merge($this->definition->defaults(), is_array($instance) ? $instance : []); + if ($this->should_lazy_load()) { + $this->render_lazy_placeholder(); + return; + } + FormRenderer::render($this, $this->definition->fields(), $instance); + } + + public function render_full_form(array $instance): void { + FormRenderer::render($this, $this->definition->fields(), array_merge($this->definition->defaults(), $instance)); + } + + public function update($new_instance, $old_instance): array { + return $this->definition->normalize(is_array($new_instance) ? $new_instance : []); + } + + private function should_lazy_load(): bool { + if (wp_doing_ajax() && isset($_REQUEST['action']) && $_REQUEST['action'] === AdminFormController::AJAX_ACTION) { + return false; + } + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + return $screen && in_array($screen->id, ['widgets', 'customize'], true); + } + + private function render_lazy_placeholder(): void { + printf( + '

    %3$s

    ', + esc_attr($this->id_base), + esc_attr((string) $this->number), + esc_html__('配置将在展开或添加模块后加载。', 'aksrt'), + esc_html__('加载配置', 'aksrt') + ); + } +} + diff --git a/src/WidgetSystem/WidgetCatalog.php b/src/WidgetSystem/WidgetCatalog.php new file mode 100644 index 00000000..ca83f142 --- /dev/null +++ b/src/WidgetSystem/WidgetCatalog.php @@ -0,0 +1,97 @@ + [ + ['search', '搜索框', '显示多种样式的站内搜索框', 'sidebar'], + ['posts_toc', '文章目录树', '显示当前文章的标题目录', 'sidebar'], + ['quote', '一言', '定时切换本地或在线句子', 'sidebar'], + ['icon_cards', '图标卡片(竖版)', '图标在文字上方的特色卡片', 'wide'], + ['video', '视频', '支持常规视频与流媒体格式', 'any'], + ['embed', '超级嵌入', '按比例嵌入外部在线内容', 'any'], + ['media_cards', '图文视频封面卡片', '显示图片、视频和文字封面卡片', 'wide'], + ['icon_cover_cards', '图标卡片(横版)', '图标在文字左侧的特色卡片', 'wide'], + ['partners', '横向滚动合作伙伴', '多行自动滚动的合作伙伴或案例列表', 'wide'], + ['combo_info', '复合信息卡片', '组合图标、标签、简介和按钮的信息卡', 'wide'], + ['text_title', '文字标题', '独立的标题与简介模块', 'wide'], + ['buttons', '按钮组', '单个或多个跳转按钮', 'wide'], + ['layout_gap', '布局额外间距', '增加 PC 与移动端模块间距', 'wide'], + ['tag_cloud', '标签云', '彩色显示标签或分类', 'sidebar'], + ['recent_comments', '最近评论', '显示网站最新评论', 'sidebar'], + ['links', '链接列表', '友情链接和网址导航列表', 'wide'], + ['notice', '上下滚动公告', '自动滚动的公告信息列表', 'wide'], + ], + 'content' => [ + ['term_cards', '分类图文卡片', '将分类或标签显示为图文卡片', 'wide'], + ['term_aggregation', '分类与标签聚合卡片', '聚合分类、标签与文章内容', 'wide'], + ['hot_posts', '热榜文章', '按浏览、评论、收藏等维度排行', 'sidebar'], + ['post_pay', '付费购买', '显示当前文章的知识付费购买模块', 'sidebar'], + ['posts', '文章列表', '多条件筛选和多样式文章列表', 'wide'], + ['tab_posts', '多栏目文章', '通过多个栏目切换文章列表', 'wide'], + ['oneline_posts', '横向滚动文章', '单行横向滚动文章列表', 'wide'], + ], + 'knowledge_product' => [ + ['pricing_plans', '知识产品方案卡片', '课程与会员方案价格、权益和购买入口', 'wide'], + ['reviews', '知识产品评价', '横向滑动的学员或会员评价', 'wide'], + ['faq', '知识产品常见问题', '支持搜索的常见问题与帮助卡片', 'wide'], + ['stats', '知识产品数据展示', '横排数字统计展示模块', 'wide'], + ['highlights', '知识产品亮点卡片', '多尺寸知识产品亮点聚合', 'wide'], + ['feature_intro', '知识产品图文亮点', '可切换媒体的左右图文介绍', 'wide'], + ['tilt_cases', '知识产品 3D 展示', '带 3D 交互的知识产品案例展示', 'wide'], + ], + 'slider' => [ + ['slider', '幻灯片', '支持图片、视频、文字和视差图层', 'wide'], + ['slider_media_combo', '幻灯片加图文视频卡片', '幻灯片与图文视频卡片组合布局', 'wide'], + ], + 'user' => [ + ['user_card', '用户个人信息', '根据登录状态显示登录入口或用户信息', 'sidebar'], + ['author_card', '文章作者信息', '显示当前文章作者与关注入口', 'sidebar'], + ['user_ranking', '用户排行榜', '按签到、积分或粉丝生成排行', 'sidebar'], + ['user_list', '用户列表', '筛选并显示注册用户列表', 'wide'], + ], + ]; + + $definitions = []; + foreach ($groups as $group => $items) { + foreach ($items as [$slug, $name, $description, $size]) { + $definitions[] = [ + 'slug' => $slug, + 'name' => 'AKSRT ' . $name, + 'description' => $description, + 'group' => $group, + 'renderer' => $slug, + 'size' => $size, + 'common' => [ + 'title' => !in_array($slug, ['post_pay', 'layout_gap'], true), + 'affix' => $size === 'sidebar', + ], + 'fields' => WidgetSchemas::for($slug), + 'assets' => self::assets($slug), + ]; + } + } + return $definitions; + } + + private static function assets(string $slug): array { + if (in_array($slug, ['slider', 'slider_media_combo', 'reviews', 'oneline_posts'], true)) { + return ['swiper']; + } + if ($slug === 'video') { + return ['dplayer', 'hls', 'flv', 'dash']; + } + if ($slug === 'tilt_cases') { + return ['tilt']; + } + return []; + } +} + diff --git a/src/WidgetSystem/WidgetDefinition.php b/src/WidgetSystem/WidgetDefinition.php new file mode 100644 index 00000000..5777fadc --- /dev/null +++ b/src/WidgetSystem/WidgetDefinition.php @@ -0,0 +1,98 @@ +slug = $slug; + $this->name = $name; + $this->description = trim((string) ($definition['description'] ?? '')); + $this->group = (string) ($definition['group'] ?? 'general'); + $this->renderer = (string) ($definition['renderer'] ?? $slug); + $this->size = in_array(($definition['size'] ?? 'any'), ['any', 'wide', 'sidebar'], true) + ? (string) $definition['size'] + : 'any'; + $this->fields = array_values((array) ($definition['fields'] ?? [])); + $this->common = array_merge( + [ + 'title' => true, + 'affix' => false, + 'visibility' => true, + 'animation' => true, + 'layout' => true, + ], + (array) ($definition['common'] ?? []) + ); + $this->assets = array_values(array_unique(array_map('strval', (array) ($definition['assets'] ?? [])))); + } + + public function slug(): string { + return $this->slug; + } + + public function id_base(): string { + return 'aksrt_widget_' . $this->slug; + } + + public function name(): string { + return $this->name; + } + + public function description(): string { + return $this->description; + } + + public function group(): string { + return $this->group; + } + + public function renderer(): string { + return $this->renderer; + } + + public function size(): string { + return $this->size; + } + + public function assets(): array { + return $this->assets; + } + + public function fields(): array { + return array_merge(CommonFields::for_definition($this->common), $this->fields); + } + + public function defaults(): array { + return FieldSchema::defaults($this->fields()); + } + + public function normalize(array $input): array { + return FieldSchema::normalize($this->fields(), $input); + } +} + diff --git a/src/WidgetSystem/WidgetRegistry.php b/src/WidgetSystem/WidgetRegistry.php new file mode 100644 index 00000000..113fe1dd --- /dev/null +++ b/src/WidgetSystem/WidgetRegistry.php @@ -0,0 +1,49 @@ +|null */ + private static ?array $definitions = null; + + /** @return array */ + public static function all(): array { + if (self::$definitions === null) { + self::$definitions = []; + foreach (WidgetCatalog::definitions() as $raw) { + $definition = new WidgetDefinition($raw); + if (isset(self::$definitions[$definition->slug()])) { + throw new \LogicException('Duplicate widget slug: ' . $definition->slug()); + } + self::$definitions[$definition->slug()] = $definition; + } + } + return self::$definitions; + } + + public static function get(string $slug): ?WidgetDefinition { + return self::all()[$slug] ?? null; + } + + public static function get_by_id_base(string $id_base): ?WidgetDefinition { + $prefix = 'aksrt_widget_'; + if (!str_starts_with($id_base, $prefix)) { + return null; + } + return self::get(substr($id_base, strlen($prefix))); + } + + public static function count(): int { + return count(self::all()); + } + + public static function reset(): void { + self::$definitions = null; + } +} + diff --git a/src/WidgetSystem/WidgetSchemas.php b/src/WidgetSystem/WidgetSchemas.php new file mode 100644 index 00000000..23538afd --- /dev/null +++ b/src/WidgetSystem/WidgetSchemas.php @@ -0,0 +1,309 @@ + 'theme', 'type' => 'palette', 'label' => '色彩主题', 'default' => 'jb-red', 'options' => CommonFields::palette()], + ]; + } + if (in_array($slug, ['pricing_plans', 'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases'], true)) { + return self::knowledge_product($slug); + } + if (in_array($slug, ['slider', 'slider_media_combo'], true)) { + return self::slider($slug); + } + if (in_array($slug, ['user_card', 'author_card', 'user_ranking', 'user_list'], true)) { + return self::user($slug); + } + return self::general($slug); + } + + private static function general(string $slug): array { + switch ($slug) { + case 'search': + return [ + ['id' => 'placeholder', 'type' => 'text', 'label' => '提示文字', 'default' => '输入关键词搜索'], + ['id' => 'search_style', 'type' => 'radio', 'label' => '显示样式', 'default' => 'button', 'options' => ['button' => '按钮式', 'compact' => '紧凑式', 'large' => '大尺寸']], + ]; + case 'posts_toc': + return [ + ['id' => 'min_headings', 'type' => 'spinner', 'label' => '最少标题数量', 'default' => 3, 'min' => 1, 'max' => 20], + ['id' => 'collapsed', 'type' => 'switcher', 'label' => '默认收起', 'default' => false], + ['id' => 'offset', 'type' => 'spinner', 'label' => '滚动偏移', 'default' => 70, 'min' => 0, 'max' => 300], + ]; + case 'quote': + return [ + ['id' => 'source', 'type' => 'select', 'label' => '内容来源', 'default' => 'local', 'options' => ['local' => '本地句子', 'api' => '在线一言']], + ['id' => 'quotes', 'type' => 'textarea', 'label' => '本地句子', 'description' => '每行一句', 'default' => "保持好奇,持续创造。\n知识因为分享而更有价值。"], + ['id' => 'interval', 'type' => 'spinner', 'label' => '自动更新间隔(秒)', 'default' => 30, 'min' => 5, 'max' => 300], + ]; + case 'video': + return [ + ['id' => 'url', 'type' => 'upload', 'label' => '视频地址', 'default' => ''], + ['id' => 'poster', 'type' => 'upload', 'label' => '视频封面', 'default' => ''], + ['id' => 'autoplay', 'type' => 'switcher', 'label' => '自动播放', 'default' => false], + ['id' => 'muted', 'type' => 'switcher', 'label' => '静音', 'default' => false], + ['id' => 'ratio', 'type' => 'select', 'label' => '长宽比例', 'default' => '16:9', 'options' => ['16:9' => '16:9', '4:3' => '4:3', '1:1' => '1:1', 'auto' => '自适应']], + ]; + case 'embed': + return [ + ['id' => 'url', 'type' => 'upload', 'label' => '嵌入地址', 'default' => ''], + ['id' => 'ratio', 'type' => 'slider', 'label' => '高度比例(%)', 'default' => 56.25, 'min' => 20, 'max' => 200, 'step' => 0.25], + ['id' => 'allow_fullscreen', 'type' => 'switcher', 'label' => '允许全屏', 'default' => true], + ]; + case 'layout_gap': + return [ + ['id' => 'pc_gap', 'type' => 'slider', 'label' => 'PC 端间隔', 'default' => 20, 'min' => 0, 'max' => 300], + ['id' => 'm_gap', 'type' => 'slider', 'label' => '移动端间隔', 'default' => 10, 'min' => 0, 'max' => 200], + ]; + case 'tag_cloud': + return [ + ['id' => 'taxonomy', 'type' => 'select', 'label' => '分类法', 'default' => 'post_tag', 'options' => ['post_tag' => '标签', 'category' => '分类']], + ['id' => 'number', 'type' => 'spinner', 'label' => '数量', 'default' => 20, 'min' => 1, 'max' => 100], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序', 'default' => 'count', 'options' => ['count' => '内容数量', 'name' => '名称', 'term_id' => 'ID', 'rand' => '随机']], + ['id' => 'show_count', 'type' => 'switcher', 'label' => '显示数量', 'default' => false], + ]; + case 'recent_comments': + return [ + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量', 'default' => 6, 'min' => 1, 'max' => 50], + ['id' => 'exclude_users', 'type' => 'text', 'label' => '排除用户 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'exclude_posts', 'type' => 'text', 'label' => '排除文章 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'show_source', 'type' => 'switcher', 'label' => '显示评论来源', 'default' => true], + ]; + case 'links': + return [ + ['id' => 'style', 'type' => 'button_set', 'label' => '样式', 'default' => 'card', 'options' => ['card' => '卡片', 'bigcard' => '大卡片', 'image' => '图片', 'simple' => '简约']], + ['id' => 'limit', 'type' => 'spinner', 'label' => '最大数量', 'default' => 12, 'min' => 1, 'max' => 100], + ['id' => 'links', 'type' => 'group', 'label' => '链接列表', 'max_items' => 100, 'fields' => self::card_fields(true)], + ]; + case 'notice': + return [ + ['id' => 'interval', 'type' => 'spinner', 'label' => '滚动间隔(秒)', 'default' => 4, 'min' => 1, 'max' => 60], + ['id' => 'items', 'type' => 'group', 'label' => '公告内容', 'max_items' => 30, 'fields' => self::card_fields(true)], + ]; + case 'text_title': + return [ + ['id' => 'heading', 'type' => 'textarea', 'label' => '标题', 'default' => ''], + ['id' => 'description', 'type' => 'textarea', 'label' => '简介', 'default' => ''], + ['id' => 'alignment', 'type' => 'button_set', 'label' => '对齐', 'default' => 'center', 'options' => ['left' => '左', 'center' => '中', 'right' => '右']], + ]; + case 'buttons': + return [ + ['id' => 'alignment', 'type' => 'button_set', 'label' => '对齐', 'default' => 'center', 'options' => ['left' => '左', 'center' => '中', 'right' => '右']], + ['id' => 'gap', 'type' => 'slider', 'label' => '按钮间距', 'default' => 10, 'min' => 0, 'max' => 60], + ['id' => 'buttons', 'type' => 'group', 'label' => '按钮', 'max_items' => 12, 'fields' => self::button_fields()], + ]; + case 'partners': + return [ + ['id' => 'rows_pc', 'type' => 'spinner', 'label' => 'PC 行数', 'default' => 2, 'min' => 1, 'max' => 5], + ['id' => 'rows_mobile', 'type' => 'spinner', 'label' => '移动端行数', 'default' => 1, 'min' => 1, 'max' => 4], + ['id' => 'speed', 'type' => 'spinner', 'label' => '滚动速度', 'default' => 30, 'min' => 5, 'max' => 120], + ['id' => 'items', 'type' => 'group', 'label' => '合作伙伴', 'max_items' => 60, 'fields' => self::media_card_fields()], + ]; + case 'combo_info': + return [['id' => 'items', 'type' => 'group', 'label' => '信息卡片', 'max_items' => 30, 'fields' => array_merge(self::media_card_fields(), [['id' => 'tags', 'type' => 'text', 'label' => '标签', 'default' => '']])]]; + case 'icon_cards': + case 'icon_cover_cards': + return [ + ['id' => 'pc_row', 'type' => 'spinner', 'label' => 'PC 单行数量', 'default' => 4, 'min' => 1, 'max' => 12], + ['id' => 'm_row', 'type' => 'spinner', 'label' => '移动端单行数量', 'default' => 2, 'min' => 1, 'max' => 6], + ['id' => 'cards', 'type' => 'group', 'label' => '卡片', 'max_items' => 40, 'fields' => self::icon_card_fields()], + ]; + case 'media_cards': + default: + return [['id' => 'cards', 'type' => 'group', 'label' => '卡片', 'max_items' => 40, 'fields' => self::media_card_fields()]]; + } + } + + private static function content(string $slug): array { + if ($slug === 'term_cards' || $slug === 'term_aggregation') { + return [ + ['id' => 'taxonomy', 'type' => 'select', 'label' => '分类法', 'default' => 'category', 'options' => ['category' => '分类', 'post_tag' => '标签']], + ['id' => 'term_ids', 'type' => 'text', 'label' => '分类/标签 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'count', 'type' => 'spinner', 'label' => '数量', 'default' => 8, 'min' => 1, 'max' => 50], + ['id' => 'pc_row', 'type' => 'spinner', 'label' => 'PC 单行数量', 'default' => 4, 'min' => 1, 'max' => 12], + ['id' => 'm_row', 'type' => 'spinner', 'label' => '移动端单行数量', 'default' => 2, 'min' => 1, 'max' => 6], + ]; + } + + $fields = self::post_query_fields(); + if ($slug === 'hot_posts') { + $fields[] = ['id' => 'show_thumbnail', 'type' => 'switcher', 'label' => '显示缩略图', 'default' => true]; + } + if ($slug === 'oneline_posts') { + $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 $fields; + } + + private static function post_query_fields(): array { + return [ + ['id' => 'category_ids', 'type' => 'text', 'label' => '分类 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'tag_ids', 'type' => 'text', 'label' => '标签 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'pay_type', 'type' => 'select', 'label' => '付费内容类型', 'default' => '', 'options' => self::pay_types()], + ['id' => 'limit_day', 'type' => 'spinner', 'label' => '最近天数', 'default' => 0, 'min' => 0, 'max' => 3650], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序', 'default' => 'date', 'options' => ['date' => '发布时间', 'modified' => '更新时间', 'views' => '浏览量', 'comments' => '评论数', 'likes' => '点赞数', 'favorites' => '收藏数', 'downloads' => '下载量', 'rand' => '随机']], + ['id' => 'order', 'type' => 'radio', 'label' => '顺序', 'default' => 'DESC', 'options' => ['DESC' => '降序', 'ASC' => '升序']], + ['id' => 'count', 'type' => 'spinner', 'label' => '数量', 'default' => 8, 'min' => 1, 'max' => 50], + ['id' => 'style', 'type' => 'button_set', 'label' => '列表样式', 'default' => 'card', 'options' => ['list' => '列表', 'card' => '卡片', 'mini' => 'Mini']], + ['id' => 'paginate', 'type' => 'select', 'label' => '翻页方式', 'default' => 'none', 'options' => ['none' => '不翻页', 'numbered' => '数字分页', 'load_more' => '加载更多', 'infinite' => '无限加载']], + ['id' => 'load_mode', 'type' => 'select', 'label' => '加载方式', 'default' => 'sync', 'options' => ['sync' => '同步', 'ajax' => 'AJAX 延迟加载']], + ]; + } + + private static function knowledge_product(string $slug): array { + $base = [ + ['id' => 'pc_row', 'type' => 'spinner', 'label' => 'PC 单行数量', 'default' => 3, 'min' => 1, 'max' => 6], + ['id' => 'm_row', 'type' => 'spinner', 'label' => '移动端单行数量', 'default' => 1, 'min' => 1, 'max' => 3], + ]; + if ($slug === 'pricing_plans') { + $base[] = ['id' => 'plans', 'type' => 'group', 'label' => '课程/会员方案', 'max_items' => 12, 'fields' => [ + ['id' => 'title', 'type' => 'text', 'label' => '方案名称', 'default' => ''], + ['id' => 'price', 'type' => 'text', 'label' => '价格', 'default' => ''], + ['id' => 'description', 'type' => 'textarea', 'label' => '简介', 'default' => ''], + ['id' => 'features', 'type' => 'textarea', 'label' => '权益(每行一项)', 'default' => ''], + ['id' => 'cta_type', 'type' => 'select', 'label' => '按钮类型', 'default' => 'link', 'options' => ['link' => '链接', 'post' => '付费文章', 'vip' => '会员方案']], + ['id' => 'post_id', 'type' => 'number', 'label' => '文章 ID', 'default' => 0, 'min' => 0], + ['id' => 'vip_level', 'type' => 'number', 'label' => '会员等级', 'default' => 1, 'min' => 1, 'max' => 20], + ['id' => 'link', 'type' => 'link', 'label' => '按钮链接', 'default' => []], + ['id' => 'featured', 'type' => 'switcher', 'label' => '推荐方案', 'default' => false], + ]]; + return $base; + } + if ($slug === 'faq') { + $base[] = ['id' => 'search', 'type' => 'switcher', 'label' => '显示搜索', 'default' => true]; + $base[] = ['id' => 'items', 'type' => 'group', 'label' => '问答', 'max_items' => 60, 'fields' => [['id' => 'question', 'type' => 'text', 'label' => '问题', 'default' => ''], ['id' => 'answer', 'type' => 'textarea', 'label' => '回答', 'default' => '', 'allow_html' => true]]]; + return $base; + } + $base[] = ['id' => 'items', 'type' => 'group', 'label' => '内容项', 'max_items' => 40, 'fields' => array_merge(self::media_card_fields(), [ + ['id' => 'value', 'type' => 'text', 'label' => '数值/徽标', 'default' => ''], + ['id' => 'features', 'type' => 'textarea', 'label' => '特性', 'default' => ''], + ['id' => 'rating', 'type' => 'slider', 'label' => '评分', 'default' => 5, 'min' => 1, 'max' => 5], + ])]; + return $base; + } + + private static function slider(string $slug): array { + $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]]], + ]], + ]; + if ($slug === 'slider_media_combo') { + $fields[] = ['id' => 'side_cards', 'type' => 'group', 'label' => '右侧图文视频卡片', 'max_items' => 6, 'fields' => self::media_card_fields()]; + } + return $fields; + } + + private static function user(string $slug): array { + if ($slug === 'user_ranking') { + return [ + ['id' => 'orderby', 'type' => 'select', 'label' => '榜单类型', 'default' => 'points', 'options' => ['checkin_all_day' => '累计签到', 'checkin_continuous_day' => '连续签到', 'points' => '积分', 'followers' => '粉丝']], + ['id' => 'number', 'type' => 'spinner', 'label' => '显示数量', 'default' => 8, 'min' => 1, 'max' => 50], + ['id' => 'show_badge', 'type' => 'switcher', 'label' => '显示前三徽标', 'default' => true], + ]; + } + if ($slug === 'user_list') { + return [ + ['id' => 'number', 'type' => 'spinner', 'label' => '显示数量', 'default' => 12, 'min' => 1, 'max' => 100], + ['id' => 'include', 'type' => 'text', 'label' => '包含用户 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'exclude', 'type' => 'text', 'label' => '排除用户 ID', 'default' => '', 'sanitize' => 'ids'], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序', 'default' => 'registered', 'options' => ['registered' => '注册时间', 'display_name' => '名称', 'post_count' => '文章数', 'points' => '积分', 'followers' => '粉丝']], + ]; + } + return [ + ['id' => 'show_cover', 'type' => 'switcher', 'label' => '显示封面', 'default' => true], + ['id' => 'show_posts', 'type' => 'switcher', 'label' => '显示文章', 'default' => $slug === 'author_card'], + ['id' => 'show_buttons', 'type' => 'switcher', 'label' => '显示快捷按钮', 'default' => true], + ['id' => 'primary_action', 'type' => 'select', 'label' => '主按钮', 'default' => 'submit', 'options' => self::user_actions()], + ['id' => 'secondary_action', 'type' => 'select', 'label' => '次按钮', 'default' => 'home', 'options' => self::user_actions()], + ]; + } + + private static function icon_card_fields(): array { + return [ + ['id' => 'title', 'type' => 'text', 'label' => '标题', 'default' => ''], + ['id' => 'description', 'type' => 'textarea', 'label' => '说明', 'default' => ''], + ['id' => 'icon', 'type' => 'icon', 'label' => '图标', 'default' => 'fa fa-star'], + ['id' => 'custom_icon', 'type' => 'upload', 'label' => '自定义图标', 'default' => ''], + ['id' => 'color', 'type' => 'palette', 'label' => '颜色', 'default' => 'jb-blue', 'options' => CommonFields::palette()], + ['id' => 'link', 'type' => 'link', 'label' => '链接', 'default' => []], + ]; + } + + private static function media_card_fields(): array { + return [ + ['id' => 'title', 'type' => 'text', 'label' => '标题', 'default' => ''], + ['id' => 'description', 'type' => 'textarea', 'label' => '说明', 'default' => ''], + ['id' => 'image', 'type' => 'upload', 'label' => '图片', 'default' => ''], + ['id' => 'image_dark', 'type' => 'upload', 'label' => '深色图片', 'default' => ''], + ['id' => 'video', 'type' => 'upload', 'label' => '视频', 'default' => ''], + ['id' => 'link', 'type' => 'link', 'label' => '链接', 'default' => []], + ]; + } + + private static function card_fields(bool $link = false): array { + $fields = [['id' => 'title', 'type' => 'text', 'label' => '标题', 'default' => ''], ['id' => 'description', 'type' => 'textarea', 'label' => '说明', 'default' => '']]; + if ($link) { + $fields[] = ['id' => 'link', 'type' => 'link', 'label' => '链接', 'default' => []]; + } + return $fields; + } + + private static function button_fields(): array { + return [ + ['id' => 'text', 'type' => 'text', 'label' => '文字', 'default' => '按钮'], + ['id' => 'icon', 'type' => 'icon', 'label' => '图标', 'default' => ''], + ['id' => 'color', 'type' => 'palette', 'label' => '颜色', 'default' => 'jb-blue', 'options' => CommonFields::palette()], + ['id' => 'link', 'type' => 'link', 'label' => '链接', 'default' => []], + ]; + } + + private static function pay_types(): array { + return ['' => '全部', 'reading' => '付费阅读', 'download' => '付费下载', 'gallery' => '付费图库', 'video' => '付费视频', 'vip' => 'VIP 专属']; + } + + private static function user_actions(): array { + return ['home' => '个人主页', 'submit' => '前台投稿', 'vip' => '会员中心', 'orders' => '订单中心', 'recharge' => '余额充值', 'signin' => '签到']; + } +} diff --git a/tools/audit-widget-system.php b/tools/audit-widget-system.php new file mode 100644 index 00000000..5a442fa4 --- /dev/null +++ b/tools/audit-widget-system.php @@ -0,0 +1,218 @@ +

      1. '); + } +} +if (!function_exists('esc_url_raw')) { + function esc_url_raw($value): string { + $value = trim((string) $value); + return filter_var($value, FILTER_VALIDATE_URL) ? $value : ''; + } +} +if (!function_exists('absint')) { + function absint($value): int { + return abs((int) $value); + } +} +if (!function_exists('sanitize_hex_color')) { + function sanitize_hex_color($value): string { + $value = (string) $value; + return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : ''; + } +} + +require dirname(__DIR__) . '/vendor/autoload.php'; + +use AKSRT\WidgetSystem\FieldSchema; +use AKSRT\WidgetSystem\PostQuery; +use AKSRT\WidgetSystem\RendererRegistry; +use AKSRT\WidgetSystem\WidgetRegistry; + +$errors = []; +$expected = [ + 'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards', + 'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap', + 'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation', + 'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans', + 'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider', + 'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list', +]; + +$registry = WidgetRegistry::all(); +if (count($registry) !== 37) { + $errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.'; +} +if (array_keys($registry) !== $expected) { + $errors[] = 'Registry slugs or ordering differ from the frozen contract.'; +} + +$supported = FieldSchema::supported_types(); +$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void { + $ids = []; + foreach ($fields as $field) { + $id = (string) ($field['id'] ?? ''); + $type = (string) ($field['type'] ?? 'text'); + if (!in_array($type, $supported, true)) { + $errors[] = $owner . ': unsupported field type ' . $type . '.'; + } + if ($id !== '') { + if (isset($ids[$id])) { + $errors[] = $owner . ': duplicate field id ' . $id . '.'; + } + $ids[$id] = true; + } + if (!empty($field['fields']) && is_array($field['fields'])) { + $walk($field['fields'], $owner . '.' . ($id ?: $type)); + } + } +}; + +foreach ($registry as $slug => $definition) { + if ($definition->id_base() !== 'aksrt_widget_' . $slug) { + $errors[] = $slug . ': invalid id_base.'; + } + if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) { + $errors[] = $slug . ': forbidden forum/shop contract.'; + } + $fields = $definition->fields(); + $walk($fields, $slug); + $field_ids = array_column($fields, 'id'); + foreach (['show_type', 'show_id_type', 'show_ids', 'animation_in', 'layout_pin_pc', 'layout_bg'] as $required) { + if (!in_array($required, $field_ids, true)) { + $errors[] = $slug . ': missing common field ' . $required . '.'; + } + } +} + +$renderers = RendererRegistry::all(); +if (count($renderers) !== 37 || array_keys($renderers) !== $expected) { + $errors[] = 'Renderer registry must map the exact 37 frozen slugs.'; +} +foreach ($renderers as $slug => $renderer) { + if (!is_callable([$renderer, 'render'])) { + $errors[] = $slug . ': renderer is not callable.'; + } +} + +$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts']; +foreach ($expected as $slug) { + if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) { + $errors[] = $slug . ': invalid dynamic items capability.'; + } +} + +if (!defined('DAY_IN_SECONDS')) { + define('DAY_IN_SECONDS', 86400); +} +$query_args = PostQuery::arguments([ + 'count' => 7, + 'category_ids' => '2,5', + 'tag_ids' => '8,13', + 'limit_day' => 30, + 'orderby' => 'views', + 'order' => 'ASC', + 'pay_type' => 'video', +], 3); +if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) { + $errors[] = 'Post query pagination contract failed.'; +} +if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) { + $errors[] = 'Post query taxonomy contract failed.'; +} +if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') { + $errors[] = 'Post query ordering contract failed.'; +} +$pay_query = json_encode($query_args['meta_query'] ?? []); +if (!is_string($pay_query) || !str_contains($pay_query, 'video')) { + $errors[] = 'Post query paid-content contract failed.'; +} + +$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php'); +foreach ([ + "'permission_callback' => '__return_true'", + "'instance' => ['type' => 'string', 'required' => true", + "'page' => ['type' => 'integer'", + "'segment' => ['type' => 'string'", + 'wp_get_sidebars_widgets()', + '$widget->get_settings()', +] as $contract) { + if (!str_contains($rest_source, $contract)) { + $errors[] = 'REST controller missing contract: ' . $contract; + } +} +if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) { + $errors[] = 'REST controller must use WP_REST_Request only.'; +} + +$frontend_files = [ + dirname(__DIR__) . '/assets/widgets/frontend.css', + dirname(__DIR__) . '/assets/widgets/frontend.js', +]; +foreach ($frontend_files as $frontend_file) { + if (!is_file($frontend_file) || filesize($frontend_file) === 0) { + $errors[] = 'Missing frontend widget asset: ' . basename($frontend_file); + } +} + +$posts = $registry['posts']; +$normalized = $posts->normalize([ + 'title' => ' 最新课程', + 'show_type' => 'invalid', + 'show_id_type' => 'show', + 'show_ids' => '3, 3,9 bad -4', + 'count' => 999, + 'style' => 'broken', + 'pay_type' => 'video', +]); +if ($normalized['count'] !== 50 || $normalized['style'] !== 'card' || $normalized['show_type'] !== 'all') { + $errors[] = 'Schema clamping or enum fallback failed.'; +} +if ($normalized['show_ids'] !== '3,9,4') { + $errors[] = 'ID list normalization failed: ' . $normalized['show_ids']; +} +if (str_contains($normalized['title'], '