feat: rewrite schema-driven widget system #4

Merged
kate522 merged 1 commits from worktree/fetch-user-tools into main 2026-08-17 09:31:18 +00:00
28 changed files with 3489 additions and 0 deletions
Showing only changes of commit ad99ab53ce - Show all commits
+35
View File
@@ -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"]
}
+40
View File
@@ -0,0 +1,40 @@
# Plan: AKSRT 小工具系统全量重写
> 用户确认基线:`C:\Users\Xing\Desktop\zibll`
> 目标:37 个现代核心小工具;排除论坛、商城和废弃旧版;无兼容、迁移或双路运行。
## Architectural Decisions
- 使用全新的 `AKSRT\WidgetSystem` 数据驱动内核,不引入 Codestar/CSF。
- 新实例 ID 统一为 `aksrt_widget_<slug>`,不注册或读取现有小工具 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 检查和管理端契约审计。
+166
View File
@@ -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; }
}
+129
View File
@@ -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 = $('<img alt="">').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);
+135
View File
@@ -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); } }
+290
View File
@@ -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 '<div class="aksrt-widget-request-error" role="alert"><span>' + (i18n.error || '加载失败,请重试。') + '</span><button type="button" data-widget-retry>' + (i18n.retry || '重试') + '</button></div>';
}
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();
})();
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Lazy widget form endpoint and admin-only assets.
*/
final class AdminFormController {
public const AJAX_ACTION = 'aksrt_widget_form';
private const NONCE_ACTION = 'aksrt_widget_form';
public static function register_hooks(): void {
add_action('wp_ajax_' . self::AJAX_ACTION, [self::class, 'ajax_form']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue']);
add_action('customize_controls_enqueue_scripts', [self::class, 'enqueue']);
}
public static function enqueue(): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || !in_array($screen->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]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/** Registers the isolated widget bundle and optional interaction libraries. */
final class AssetManager {
private static bool $registered = false;
private static bool $configured = false;
public static function enqueue_for(WidgetDefinition $definition): void {
self::register();
foreach ($definition->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');
}
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Reference-equivalent fields shared by all modern widgets.
*/
final class CommonFields {
public static function for_definition(array $flags): array {
$fields = [];
if (!empty($flags['title'])) {
$fields = array_merge($fields, self::title_fields());
}
if (!empty($flags['affix'])) {
$fields[] = ['id' => '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' => '红色',
];
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Field schema defaults and recursive value normalization.
*/
final class FieldSchema {
private const DISPLAY_ONLY = ['content', 'subheading', 'submessage'];
public static function supported_types(): array {
return [
'text', 'textarea', 'number', 'spinner', 'slider', 'switcher', 'radio',
'button_set', 'select', 'checkbox', 'color', 'palette', 'icon', 'upload',
'link', 'spacing', 'background', 'group', 'fieldset', 'accordion',
'content', 'subheading', 'submessage',
];
}
public static function defaults(array $fields): array {
$defaults = [];
foreach ($fields as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, self::DISPLAY_ONLY, true)) {
continue;
}
$defaults[$id] = self::normalize_value($field, $field['default'] ?? self::empty_value($type));
}
return $defaults;
}
public static function normalize(array $fields, array $input): array {
$output = [];
foreach ($fields as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, self::DISPLAY_ONLY, true)) {
continue;
}
$value = array_key_exists($id, $input) ? $input[$id] : ($field['default'] ?? self::empty_value($type));
$output[$id] = self::normalize_value($field, $value);
}
return $output;
}
public static function normalize_value(array $field, $value) {
$type = (string) ($field['type'] ?? 'text');
switch ($type) {
case 'switcher':
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
case 'number':
case 'spinner':
case 'slider':
return self::number($field, $value);
case 'radio':
case 'button_set':
case 'select':
case 'palette':
return self::choice($field, $value);
case 'checkbox':
return self::checkbox($field, $value);
case 'link':
return self::link($value);
case 'spacing':
return self::spacing($value);
case 'background':
return self::background($value);
case 'group':
return self::group($field, $value);
case 'fieldset':
case 'accordion':
return self::nested($field, $value);
case 'upload':
return esc_url_raw((string) $value);
case 'color':
return self::color((string) $value);
case 'icon':
return sanitize_text_field((string) $value);
case 'textarea':
return !empty($field['allow_html']) ? wp_kses_post((string) $value) : sanitize_textarea_field((string) $value);
case 'text':
default:
if (($field['sanitize'] ?? '') === 'ids') {
return implode(',', array_values(array_unique(array_filter(array_map('absint', preg_split('/[,,\s]+/', (string) $value))))));
}
return !empty($field['allow_html']) ? wp_kses_post((string) $value) : sanitize_text_field((string) $value);
}
}
private static function empty_value(string $type) {
if ($type === 'switcher') {
return false;
}
if (in_array($type, ['group', 'checkbox'], true)) {
return [];
}
if (in_array($type, ['link', 'spacing', 'background', 'fieldset', 'accordion'], true)) {
return [];
}
return '';
}
private static function number(array $field, $value) {
$number = is_numeric($value) ? (float) $value : (float) ($field['default'] ?? 0);
if (isset($field['min'])) {
$number = max((float) $field['min'], $number);
}
if (isset($field['max'])) {
$number = min((float) $field['max'], $number);
}
$step = (float) ($field['step'] ?? 1);
return $step >= 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;
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Server-rendered, schema-driven widget form.
*/
final class FormRenderer {
public static function render(SchemaWidget $widget, array $fields, array $instance): void {
echo '<div class="aksrt-widget-form" data-aksrt-widget-form="1">';
foreach ($fields as $field) {
self::field($widget, $field, $instance[(string) ($field['id'] ?? '')] ?? ($field['default'] ?? ''), '');
}
echo '</div>';
}
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 '<div class="aksrt-widget-field aksrt-widget-field--' . esc_attr($type) . '" data-field-id="' . esc_attr($id) . '"';
if ($dependency !== '') {
echo ' data-dependency="' . esc_attr($dependency) . '"';
}
echo '>';
if (!empty($field['label'])) {
echo '<label class="aksrt-widget-field__label" for="' . esc_attr($field_id) . '">' . esc_html((string) $field['label']) . '</label>';
}
if (!empty($field['description'])) {
echo '<p class="description">' . wp_kses_post((string) $field['description']) . '</p>';
}
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 '<textarea class="widefat" rows="4" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '">' . esc_textarea((string) $value) . '</textarea>';
break;
case 'upload':
self::upload($field_id, $field_name, (string) $value);
break;
case 'color':
echo '<input class="widefat aksrt-widget-color" type="text" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '" value="' . esc_attr((string) $value) . '">';
break;
case 'icon':
echo '<div class="aksrt-widget-icon"><i class="' . esc_attr((string) $value) . '" aria-hidden="true"></i><input class="widefat" type="text" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '" value="' . esc_attr((string) $value) . '" placeholder="fa fa-star"></div>';
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 '<input class="widefat" type="text" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_name) . '" value="' . esc_attr((string) $value) . '">';
}
echo '</div>';
}
private static function display_field(array $field, string $type): void {
$content = (string) ($field['content'] ?? $field['label'] ?? '');
echo '<div class="aksrt-widget-field aksrt-widget-field--' . esc_attr($type) . '">' . wp_kses_post($content) . '</div>';
}
private static function switcher(string $id, string $name, bool $value): void {
echo '<input type="hidden" name="' . esc_attr($name) . '" value="0">';
echo '<label class="aksrt-widget-switch"><input type="checkbox" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="1" ' . checked($value, true, false) . '><span aria-hidden="true"></span></label>';
}
private static function radios(string $id, string $name, array $options, string $value, string $type): void {
echo '<div class="aksrt-widget-choices aksrt-widget-choices--' . esc_attr($type) . '">';
foreach ($options as $key => $label) {
$option_id = $id . '-' . sanitize_html_class((string) $key);
echo '<label for="' . esc_attr($option_id) . '"><input type="radio" id="' . esc_attr($option_id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $key) . '" ' . checked($value, (string) $key, false) . '><span>' . esc_html((string) $label) . '</span></label>';
}
echo '</div>';
}
private static function select(string $id, string $name, array $field, $value): void {
$multiple = !empty($field['multiple']);
echo '<select class="widefat" id="' . esc_attr($id) . '" name="' . esc_attr($name) . ($multiple ? '[]' : '') . '"' . ($multiple ? ' multiple' : '') . '>';
$values = $multiple ? array_map('strval', (array) $value) : [(string) $value];
foreach ((array) ($field['options'] ?? []) as $key => $label) {
echo '<option value="' . esc_attr((string) $key) . '" ' . selected(in_array((string) $key, $values, true), true, false) . '>' . esc_html((string) $label) . '</option>';
}
echo '</select>';
}
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 '<label><input type="checkbox" name="' . esc_attr($name) . '[]" value="' . esc_attr((string) $key) . '" ' . checked(in_array((string) $key, $values, true), true, false) . '> ' . esc_html((string) $label) . '</label> ';
}
}
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 '<div class="aksrt-widget-number"><input class="widefat" type="' . esc_attr($input_type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '"' . $min . $max . $step . '><output>' . esc_html((string) $value) . '</output></div>';
}
private static function upload(string $id, string $name, string $value): void {
echo '<div class="aksrt-widget-upload">';
if ($value !== '') {
echo '<img src="' . esc_url($value) . '" alt="" loading="lazy">';
}
echo '<input class="widefat" type="url" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '">';
echo '<button type="button" class="button aksrt-widget-upload__choose">' . esc_html__('选择媒体', 'aksrt') . '</button> <button type="button" class="button-link-delete aksrt-widget-upload__remove">' . esc_html__('移除', 'aksrt') . '</button></div>';
}
private static function link(SchemaWidget $widget, string $key, array $value): void {
echo '<div class="aksrt-widget-link">';
self::plain_input($widget, $key . '[url]', (string) ($value['url'] ?? ''), 'url', 'URL');
self::plain_input($widget, $key . '[text]', (string) ($value['text'] ?? ''), 'text', '链接文字');
$name = $widget->get_field_name($key . '[target]');
echo '<label><input type="checkbox" name="' . esc_attr($name) . '" value="_blank" ' . checked(($value['target'] ?? '') === '_blank', true, false) . '> ' . esc_html__('新窗口打开', 'aksrt') . '</label></div>';
}
private static function spacing(SchemaWidget $widget, string $key, array $value): void {
echo '<div class="aksrt-widget-spacing">';
foreach (['top' => '上', 'right' => '右', 'bottom' => '下', 'left' => '左'] as $edge => $label) {
echo '<label><span>' . esc_html($label) . '</span>';
self::plain_input($widget, $key . '[' . $edge . ']', (string) ($value[$edge] ?? ''), 'number', 'px');
echo '</label>';
}
echo '</div>';
}
private static function background(SchemaWidget $widget, string $key, array $value): void {
echo '<div class="aksrt-widget-background">';
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 '<select name="' . esc_attr($name) . '">';
foreach ($options as $option => $label) {
echo '<option value="' . esc_attr($option) . '" ' . selected((string) ($value[$part] ?? ''), $option, false) . '>' . esc_html($label) . '</option>';
}
echo '</select>';
}
echo '</div>';
}
private static function group(SchemaWidget $widget, array $field, array $rows, string $key): void {
$token = '__index_' . substr(md5($key), 0, 8) . '__';
echo '<div class="aksrt-widget-repeater" data-index-token="' . esc_attr($token) . '" data-max-items="' . esc_attr((string) ($field['max_items'] ?? 50)) . '"><div class="aksrt-widget-repeater__rows">';
foreach ($rows as $index => $row) {
self::group_row($widget, $field, is_array($row) ? $row : [], $key, (string) $index);
}
echo '</div><template class="aksrt-widget-repeater__template">';
self::group_row($widget, $field, [], $key, $token);
echo '</template><button type="button" class="button aksrt-widget-repeater__add">' . esc_html__('添加项目', 'aksrt') . '</button></div>';
}
private static function group_row(SchemaWidget $widget, array $field, array $row, string $key, string $index): void {
echo '<div class="aksrt-widget-repeater__row"><div class="aksrt-widget-repeater__bar"><span class="dashicons dashicons-move" aria-hidden="true"></span><strong>' . esc_html__('项目', 'aksrt') . '</strong><button type="button" class="button-link-delete aksrt-widget-repeater__remove">' . esc_html__('删除', 'aksrt') . '</button></div><div class="aksrt-widget-repeater__fields">';
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
self::field($widget, $child, $row[$child_id] ?? ($child['default'] ?? ''), $key . '[' . $index . ']');
}
echo '</div></div>';
}
private static function nested(SchemaWidget $widget, array $field, array $value, string $key): void {
echo '<details class="aksrt-widget-nested"><summary>' . esc_html((string) ($field['label'] ?? '更多设置')) . '</summary><div class="aksrt-widget-nested__body">';
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
self::field($widget, $child, $value[$child_id] ?? ($child['default'] ?? ''), $key);
}
echo '</div></details>';
}
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 '<input class="widefat" type="' . esc_attr($type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '">';
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Applies the common widget contract before dispatching business markup.
*/
final class FrontendRenderer {
public static function render(WidgetDefinition $definition, array $args, array $instance, SchemaWidget $widget): void {
if (!self::is_visible($instance)) {
return;
}
$context = [
'widget_id' => (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'] ?? '<section>'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="' . esc_attr(implode(' ', $classes)) . '" data-aksrt-widget="' . esc_attr($definition->slug()) . '" data-widget-id="' . esc_attr((string) $widget->id) . '"' . ($style !== '' ? ' style="' . esc_attr($style) . '"' : '') . '>';
echo self::title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="aksrt-widget__content">' . $content . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
echo (string) ($args['after_widget'] ?? '</section>'); // 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, '<mark class="' . esc_attr((string) ($instance['title_highlight_color'] ?? 'focus-color')) . '">' . $safe_highlight . '</mark>', $safe_title);
}
$html = '<header class="aksrt-widget__header' . (($instance['title_style'] ?? '') === 'big' ? ' aksrt-widget__header--big' : '') . '"><div><h2 class="aksrt-widget__title">' . nl2br($safe_title) . '</h2>';
if (!empty($instance['subtitle'])) {
$html .= '<p class="aksrt-widget__subtitle">' . nl2br(esc_html((string) $instance['subtitle'])) . '</p>';
}
$html .= '</div><nav class="aksrt-widget__title-links" aria-label="' . esc_attr__('模块链接', 'aksrt') . '">';
foreach (['title_link', 'title_link2', 'title_link3'] as $index => $key) {
$link = RendererHelpers::link((array) ($instance[$key] ?? []));
if ($link['url'] !== '' && $link['text'] !== '') {
$color = (string) ($instance['title_link_style']['color_' . ($index + 1)] ?? '');
$html .= '<a class="' . esc_attr($color) . '" href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '>' . esc_html($link['text']) . '</a>';
}
}
return $html . '</nav></header>';
}
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);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Builds post queries exclusively from normalized, saved widget settings.
*/
final class PostQuery {
private const PAY_TYPES = ['reading', 'download', 'gallery', 'video', 'vip'];
public static function build(array $instance, int $page = 1, array $segment = []): \WP_Query {
return new \WP_Query(self::arguments($instance, $page, $segment));
}
/** @return array<string,mixed> */
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'],
]];
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Shared list/card/mini markup and pagination payloads.
*/
final class PostRenderer {
public static function payload(array $instance, string $slug, int $page = 1, string $segment = ''): array {
$segment_config = $slug === 'tab_posts' ? PostQuery::segment($instance, $segment) : [];
$query = PostQuery::build($instance, $page, $segment_config);
$html = self::items($query, $instance, $slug, $page);
$max_pages = max(1, (int) $query->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 = '<div class="aksrt-widget-posts aksrt-widget-posts--fallback">';
while ($query->have_posts()) {
$query->the_post();
$html .= '<article><a href="' . esc_url(get_permalink()) . '">' . get_the_post_thumbnail(get_the_ID(), 'medium') . '<strong>' . esc_html(get_the_title()) . '</strong></a></article>';
}
wp_reset_postdata();
return $html . '</div>';
}
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]))
: '<a href="' . esc_url(get_permalink()) . '">' . esc_html(get_the_title()) . '</a>';
$slides .= '<div class="swiper-slide">' . $item . '</div>';
}
wp_reset_postdata();
return '<div class="swiper aksrt-widget-swiper aksrt-widget-oneline" data-aksrt-swiper="scroll" data-autoplay="' . ($autoplay ? '1' : '0') . '"><div class="swiper-wrapper">' . $slides . '</div><button class="swiper-button-prev" type="button" aria-label="' . esc_attr__('上一组', 'aksrt') . '"></button><button class="swiper-button-next" type="button" aria-label="' . esc_attr__('下一组', 'aksrt') . '"></button></div>';
}
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 = '<nav class="aksrt-widget-pagination" aria-label="' . esc_attr__('文章分页', 'aksrt') . '">';
for ($number = $start; $number <= $end; $number++) {
$html .= '<button type="button" data-widget-page="' . $number . '"' . ($number === $page ? ' aria-current="page"' : '') . '>' . $number . '</button>';
}
return $html . '</nav>';
}
$label = $mode === 'infinite' ? __('继续加载', 'aksrt') : __('加载更多', 'aksrt');
return '<button class="aksrt-widget-load-more" type="button" data-next-page="' . (int) ($page + 1) . '" data-mode="' . esc_attr($mode) . '">' . esc_html($label) . '</button>';
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Small HTML primitives shared by the renderer groups.
*/
final class RendererHelpers {
public static function link(array $link): array {
$url = esc_url((string) ($link['url'] ?? ''));
$text = sanitize_text_field((string) ($link['text'] ?? ''));
$target = ($link['target'] ?? '') === '_blank' ? '_blank' : '';
return ['url' => $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 '<img' . ($class !== '' ? ' class="' . esc_attr($class) . '"' : '')
. ' src="' . esc_url($url) . '" alt="' . esc_attr($alt) . '" loading="lazy">';
}
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 '<div class="aksrt-widget__empty" role="status">' . esc_html($message) . '</div>';
}
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 .= '<video class="aksrt-media-card__video" src="' . esc_url((string) $item['video']) . '" muted loop playsinline preload="metadata"></video>';
}
$body = '<span class="aksrt-media-card__body"><strong>' . esc_html($title) . '</strong>';
if (!empty($item['description'])) {
$body .= '<small>' . esc_html((string) $item['description']) . '</small>';
}
$body .= '</span>';
$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 . '><span class="aksrt-media-card__media">' . $media . '</span>' . $body . '</' . $tag . '>';
}
public static function ids(string $value): array {
return array_values(array_filter(array_map('absint', preg_split('/\s*,\s*/', $value) ?: [])));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace AKSRT\WidgetSystem;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
use AKSRT\WidgetSystem\Renderers\GeneralRenderer;
use AKSRT\WidgetSystem\Renderers\KnowledgeProductRenderer;
use AKSRT\WidgetSystem\Renderers\SliderRenderer;
use AKSRT\WidgetSystem\Renderers\UserRenderer;
defined('ABSPATH') || exit;
/**
* One explicit dispatch table for every modern widget renderer.
*/
final class RendererRegistry {
/** @return array<string, class-string> */
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);
}
}
@@ -0,0 +1,136 @@
<?php
namespace AKSRT\WidgetSystem\Renderers;
use AKSRT\WidgetSystem\PostRenderer;
use AKSRT\WidgetSystem\RendererHelpers;
defined('ABSPATH') || exit;
/** Render article discovery and paid-content widgets. */
final class ContentRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
switch ($slug) {
case 'term_cards': return self::term_cards($instance);
case 'term_aggregation': return self::term_aggregation($instance);
case 'post_pay': return self::post_pay($instance);
case 'tab_posts': return self::tab_posts($instance, $context);
case 'posts':
case 'hot_posts':
case 'oneline_posts': return self::posts($slug, $instance, $context);
}
return '';
}
private static function terms(array $instance): array {
$args = [
'taxonomy' => (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 = '<div class="aksrt-widget-grid aksrt-term-cards" style="--aksrt-cols:' . (int) $instance['pc_row'] . ';--aksrt-cols-m:' . (int) $instance['m_row'] . '">';
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 .= '<a class="aksrt-term-card" href="' . esc_url($link) . '"><span class="aksrt-term-card__media">' . RendererHelpers::image($image, $term->name) . '</span><span class="aksrt-term-card__body"><strong>' . esc_html($term->name) . '</strong><small>' . esc_html(wp_trim_words((string) $term->description, 18)) . '</small><em>' . sprintf(esc_html__('%s 篇内容', 'aksrt'), number_format_i18n((int) $term->count)) . '</em></span></a>';
}
return $html . '</div>';
}
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 = '<div class="aksrt-term-aggregation">';
foreach ($terms as $term) {
$link = get_term_link($term);
if (is_wp_error($link)) {
continue;
}
$html .= '<section class="aksrt-term-aggregation__card"><header><a href="' . esc_url($link) . '"><strong>' . esc_html($term->name) . '</strong><span>' . esc_html__('查看全部', 'aksrt') . '</span></a></header><ul>';
foreach ($by_term[$term->term_id] ?? [] as $post) {
$html .= '<li><a href="' . esc_url(get_permalink($post)) . '">' . get_the_post_thumbnail($post, 'thumbnail') . '<span>' . esc_html(get_the_title($post)) . '</span></a></li>';
}
$html .= '</ul></section>';
}
return $html . '</div>';
}
private static function posts(string $slug, array $instance, array $context): string {
$widget_id = (string) ($context['widget_id'] ?? '');
if (($instance['load_mode'] ?? 'sync') === 'ajax') {
return '<div class="aksrt-widget-items is-loading" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1"><div class="aksrt-widget-skeleton" aria-label="' . esc_attr__('正在加载', 'aksrt') . '"></div></div>';
}
$payload = PostRenderer::payload($instance, $slug, 1);
return '<div class="aksrt-widget-items" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1">' . $payload['html'] . $payload['paginate_html'] . '</div>';
}
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 .= '<button type="button" role="tab" aria-selected="' . ($index === 0 ? 'true' : 'false') . '" data-segment="' . $index . '">' . esc_html((string) ($tab['title'] ?? sprintf(__('栏目 %d', 'aksrt'), $index + 1))) . '</button>';
}
$payload = PostRenderer::payload($instance, 'tab_posts', 1, '0');
return '<div class="aksrt-widget-tabs" data-widget="tab_posts" data-instance="' . esc_attr((string) ($context['widget_id'] ?? '')) . '"><div class="aksrt-widget-tabs__nav" role="tablist">' . $buttons . '</div><div class="aksrt-widget-tabs__panel" role="tabpanel" data-widget-items data-segment="0">' . $payload['html'] . $payload['paginate_html'] . '</div></div>';
}
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 = '<div class="aksrt-post-pay is-authorized ' . esc_attr((string) $instance['theme']) . '"><i class="fas fa-check-circle" aria-hidden="true"></i><strong>' . esc_html__('内容已解锁', 'aksrt') . '</strong><p>' . esc_html__('你已拥有当前知识内容的访问权限。', 'aksrt') . '</p>';
$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 . '</div>';
}
return function_exists('aksrt_get_paywall_html') ? aksrt_get_paywall_html($post, true) : RendererHelpers::empty_state(__('请登录或购买后查看', 'aksrt'));
}
}
@@ -0,0 +1,196 @@
<?php
namespace AKSRT\WidgetSystem\Renderers;
use AKSRT\WidgetSystem\RendererHelpers;
defined('ABSPATH') || exit;
/** Render the 17 general and decorative widgets. */
final class GeneralRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
$method = 'render_' . $slug;
return is_callable([self::class, $method]) ? (string) self::$method($instance, $context) : '';
}
private static function render_search(array $i): string {
$action = esc_url(home_url('/'));
$placeholder = esc_attr((string) $i['placeholder']);
$style = sanitize_html_class((string) $i['search_style']);
return '<form class="aksrt-widget-search aksrt-widget-search--' . $style . '" role="search" method="get" action="' . $action . '"><label><span class="screen-reader-text">' . esc_html__('搜索:', 'aksrt') . '</span><input type="search" name="s" placeholder="' . $placeholder . '" autocomplete="off"></label><button type="submit" aria-label="' . esc_attr__('搜索', 'aksrt') . '"><i class="fas fa-search" aria-hidden="true"></i><span>' . esc_html__('搜索', 'aksrt') . '</span></button></form>';
}
private static function render_posts_toc(array $i): string {
if (!is_singular('post')) {
return '';
}
return '<nav class="aksrt-widget-toc' . (!empty($i['collapsed']) ? ' is-collapsed' : '') . '" data-min-headings="' . (int) $i['min_headings'] . '" data-scroll-offset="' . (int) $i['offset'] . '" aria-label="' . esc_attr__('文章目录', 'aksrt') . '"><button class="aksrt-widget-toc__toggle" type="button" aria-expanded="' . (empty($i['collapsed']) ? 'true' : 'false') . '"><span>' . esc_html__('文章目录', 'aksrt') . '</span><i class="fas fa-chevron-down" aria-hidden="true"></i></button><ol class="aksrt-widget-toc__list"></ol><p class="aksrt-widget-toc__empty">' . esc_html__('正在生成目录…', 'aksrt') . '</p></nav>';
}
private static function render_quote(array $i): string {
$quotes = RendererHelpers::lines((string) $i['quotes']);
$text = $quotes ? $quotes[array_rand($quotes)] : __('保持好奇,持续创造。', 'aksrt');
return '<figure class="aksrt-widget-quote" data-source="' . esc_attr((string) $i['source']) . '" data-interval="' . (int) $i['interval'] . '"><i class="fas fa-quote-left" aria-hidden="true"></i><blockquote>' . esc_html($text) . '</blockquote><button type="button" aria-label="' . esc_attr__('换一句', 'aksrt') . '"><i class="fas fa-sync-alt" aria-hidden="true"></i></button></figure>';
}
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 = '<div class="aksrt-widget-grid aksrt-widget-icon-cards' . ($cover ? ' aksrt-widget-icon-cards--cover' : '') . '" style="--aksrt-cols:' . $pc . ';--aksrt-cols-m:' . $mobile . '">';
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) : '') . '><i class="' . esc_attr((string) ($card['icon'] ?? 'fa fa-star')) . '" aria-hidden="true"></i><span><strong>' . esc_html((string) ($card['title'] ?? '')) . '</strong><small>' . esc_html((string) ($card['description'] ?? '')) . '</small></span></' . $tag . '>';
}
}
return $html . '</div>';
}
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 '<div class="aksrt-widget-video aksrt-widget-video--' . esc_attr(str_replace(':', '-', $ratio)) . '" data-video-url="' . $url . '" data-video-poster="' . esc_url((string) $i['poster']) . '" data-autoplay="' . (!empty($i['autoplay']) ? '1' : '0') . '" data-muted="' . (!empty($i['muted']) ? '1' : '0') . '"><video controls playsinline preload="metadata"' . (!empty($i['autoplay']) ? ' autoplay' : '') . (!empty($i['muted']) ? ' muted' : '') . (!empty($i['poster']) ? ' poster="' . esc_url((string) $i['poster']) . '"' : '') . '><source src="' . $url . '"></video><p class="aksrt-widget-media-error" hidden>' . esc_html__('视频加载失败,请稍后重试。', 'aksrt') . '</p></div>';
}
private static function render_embed(array $i): string {
if (empty($i['url'])) {
return RendererHelpers::empty_state(__('请配置嵌入地址', 'aksrt'));
}
return '<div class="aksrt-widget-embed" style="--aksrt-embed-ratio:' . esc_attr((string) (float) $i['ratio']) . '%"><iframe src="' . esc_url((string) $i['url']) . '" loading="lazy" referrerpolicy="strict-origin-when-cross-origin"' . (!empty($i['allow_fullscreen']) ? ' allowfullscreen' : '') . ' title="' . esc_attr__('嵌入内容', 'aksrt') . '"></iframe></div>';
}
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 = '<div class="aksrt-widget-grid aksrt-widget-media-cards ' . esc_attr($class) . '">';
foreach ($items as $item) {
if (is_array($item)) {
$html .= RendererHelpers::card_media($item);
}
}
return $html . '</div>';
}
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 '<div class="aksrt-widget-partners" style="--aksrt-partner-rows:' . (int) $i['rows_pc'] . ';--aksrt-partner-rows-m:' . (int) $i['rows_mobile'] . ';--aksrt-partner-speed:' . (int) $i['speed'] . 's"><div class="aksrt-widget-partners__track">' . $items . $items . '</div></div>';
}
private static function render_combo_info(array $i): string {
$html = '<div class="aksrt-widget-grid aksrt-widget-combo">';
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 .= '<span>' . esc_html($tag) . '</span>';
}
}
$html .= '<article class="aksrt-combo-card">' . RendererHelpers::card_media($item) . ($tags ? '<div class="aksrt-combo-card__tags">' . $tags . '</div>' : '') . '</article>';
}
return $html . '</div>';
}
private static function render_text_title(array $i): string {
return '<div class="aksrt-widget-text-title is-' . esc_attr((string) $i['alignment']) . '"><h2>' . nl2br(esc_html((string) $i['heading'])) . '</h2><p>' . nl2br(esc_html((string) $i['description'])) . '</p></div>';
}
private static function render_buttons(array $i): string {
$html = '<div class="aksrt-widget-buttons is-' . esc_attr((string) $i['alignment']) . '" style="--aksrt-button-gap:' . (float) $i['gap'] . 'px">';
foreach ((array) $i['buttons'] as $button) {
$link = RendererHelpers::link((array) ($button['link'] ?? []));
if ($link['url'] === '') {
continue;
}
$html .= '<a class="aksrt-widget-button ' . esc_attr((string) ($button['color'] ?? 'jb-blue')) . '" href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '>' . (!empty($button['icon']) ? '<i class="' . esc_attr((string) $button['icon']) . '" aria-hidden="true"></i>' : '') . '<span>' . esc_html((string) ($button['text'] ?? $link['text'])) . '</span></a>';
}
return $html . '</div>';
}
private static function render_layout_gap(array $i): string {
return '<div class="aksrt-widget-gap" aria-hidden="true" style="--aksrt-gap:' . (float) $i['pc_gap'] . 'px;--aksrt-gap-m:' . (float) $i['m_gap'] . 'px"></div>';
}
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 = '<div class="aksrt-widget-tag-cloud">';
foreach (array_slice($terms, 0, (int) $i['number']) as $term) {
$url = get_term_link($term);
if (!is_wp_error($url)) {
$html .= '<a href="' . esc_url($url) . '">' . esc_html($term->name) . (!empty($i['show_count']) ? '<small>' . (int) $term->count . '</small>' : '') . '</a>';
}
}
return $html . '</div>';
}
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 = '<ul class="aksrt-widget-comments">';
foreach ($comments as $comment) {
$html .= '<li><a class="aksrt-widget-comments__avatar" href="' . esc_url(get_comment_link($comment)) . '">' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($comment, 48) : get_avatar($comment, 48)) . '</a><div><strong>' . esc_html(get_comment_author($comment)) . '</strong><p>' . esc_html(wp_trim_words(wp_strip_all_tags((string) $comment->comment_content), 18)) . '</p>' . (!empty($i['show_source']) ? '<a class="aksrt-widget-comments__source" href="' . esc_url(get_comment_link($comment)) . '">' . esc_html(get_the_title((int) $comment->comment_post_ID)) . '</a>' : '') . '</div></li>';
}
return $html . '</ul>';
}
private static function render_links(array $i): string {
$html = '<div class="aksrt-widget-links aksrt-widget-links--' . esc_attr((string) $i['style']) . '">';
foreach (array_slice((array) $i['links'], 0, (int) $i['limit']) as $item) {
$link = RendererHelpers::link((array) ($item['link'] ?? []));
if ($link['url']) {
$html .= '<a href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '><strong>' . esc_html((string) ($item['title'] ?? $link['text'])) . '</strong><small>' . esc_html((string) ($item['description'] ?? '')) . '</small></a>';
}
}
return $html . '</div>';
}
private static function render_notice(array $i): string {
$slides = '';
foreach ((array) $i['items'] as $item) {
$link = RendererHelpers::link((array) ($item['link'] ?? []));
$content = '<strong>' . esc_html((string) ($item['title'] ?? '')) . '</strong><span>' . esc_html((string) ($item['description'] ?? '')) . '</span>';
$slides .= '<div class="swiper-slide">' . ($link['url'] ? '<a href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '>' . $content . '</a>' : $content) . '</div>';
}
return '<div class="aksrt-widget-notice"><i class="fas fa-bullhorn" aria-hidden="true"></i><div class="swiper aksrt-widget-swiper" data-aksrt-swiper="notice" data-interval="' . ((int) $i['interval'] * 1000) . '"><div class="swiper-wrapper">' . $slides . '</div></div></div>';
}
}
@@ -0,0 +1,119 @@
<?php
namespace AKSRT\WidgetSystem\Renderers;
use AKSRT\WidgetSystem\RendererHelpers;
defined('ABSPATH') || exit;
/** Render the seven knowledge-product presentation widgets. */
final class KnowledgeProductRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
unset($context);
switch ($slug) {
case 'pricing_plans': return self::pricing($instance);
case 'reviews': return self::reviews($instance);
case 'faq': return self::faq($instance);
case 'stats': return self::stats($instance);
case 'highlights': return self::highlights($instance);
case 'feature_intro': return self::feature_intro($instance);
case 'tilt_cases': return self::tilt($instance);
}
return '';
}
private static function pricing(array $i): string {
$html = '<div class="aksrt-widget-grid aksrt-pricing" style="--aksrt-cols:' . (int) $i['pc_row'] . ';--aksrt-cols-m:' . (int) $i['m_row'] . '">';
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 .= '<article class="aksrt-pricing-card' . (!empty($plan['featured']) ? ' is-featured' : '') . '">';
if (!empty($plan['featured'])) {
$html .= '<span class="aksrt-pricing-card__badge">' . esc_html__('推荐', 'aksrt') . '</span>';
}
$html .= '<h3>' . esc_html((string) ($plan['title'] ?? '')) . '</h3><div class="aksrt-pricing-card__price">' . esc_html((string) ($plan['price'] ?? '')) . '</div><p>' . esc_html((string) ($plan['description'] ?? '')) . '</p><ul>';
foreach (RendererHelpers::lines((string) ($plan['features'] ?? '')) as $feature) {
$html .= '<li><i class="fas fa-check" aria-hidden="true"></i>' . esc_html($feature) . '</li>';
}
$html .= '</ul>' . ($url !== '' ? '<a class="aksrt-widget-button" href="' . esc_url($url) . '">' . esc_html($label) . '</a>' : '') . '</article>';
}
return $html . '</div>';
}
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 .= '<div class="swiper-slide"><article class="aksrt-review-card"><div class="aksrt-review-card__rating" aria-label="' . esc_attr(sprintf(__('%d 星评价', 'aksrt'), $rating)) . '">' . str_repeat('<i class="fas fa-star" aria-hidden="true"></i>', $rating) . '</div><blockquote>' . esc_html((string) ($item['description'] ?? '')) . '</blockquote><footer>' . RendererHelpers::image((string) ($item['image'] ?? ''), '', 'aksrt-review-card__avatar') . '<span><strong>' . esc_html((string) ($item['title'] ?? '')) . '</strong><small>' . esc_html((string) ($item['value'] ?? '')) . '</small></span></footer></article></div>';
}
return '<div class="swiper aksrt-widget-swiper aksrt-reviews" data-aksrt-swiper="cards"><div class="swiper-wrapper">' . $slides . '</div><div class="swiper-pagination"></div></div>';
}
private static function faq(array $i): string {
$html = '<div class="aksrt-faq">';
if (!empty($i['search'])) {
$html .= '<label class="aksrt-faq__search"><i class="fas fa-search" aria-hidden="true"></i><span class="screen-reader-text">' . esc_html__('搜索常见问题', 'aksrt') . '</span><input type="search" placeholder="' . esc_attr__('搜索问题', 'aksrt') . '"></label>';
}
$html .= '<div class="aksrt-faq__items">';
foreach ((array) $i['items'] as $index => $item) {
$id = 'aksrt-faq-' . $index . '-' . wp_rand(1000, 9999);
$html .= '<section class="aksrt-faq__item" data-search="' . esc_attr(wp_strip_all_tags((string) ($item['question'] ?? '') . ' ' . (string) ($item['answer'] ?? ''))) . '"><button type="button" aria-expanded="false" aria-controls="' . esc_attr($id) . '"><span>' . esc_html((string) ($item['question'] ?? '')) . '</span><i class="fas fa-plus" aria-hidden="true"></i></button><div id="' . esc_attr($id) . '" hidden>' . wp_kses_post((string) ($item['answer'] ?? '')) . '</div></section>';
}
return $html . '</div></div>';
}
private static function stats(array $i): string {
$html = '<div class="aksrt-widget-grid aksrt-stats" style="--aksrt-cols:' . (int) $i['pc_row'] . ';--aksrt-cols-m:' . (int) $i['m_row'] . '">';
foreach ((array) $i['items'] as $item) {
$html .= '<article class="aksrt-stat"><strong data-count-up="' . esc_attr((string) ($item['value'] ?? '')) . '">' . esc_html((string) ($item['value'] ?? '')) . '</strong><h3>' . esc_html((string) ($item['title'] ?? '')) . '</h3><p>' . esc_html((string) ($item['description'] ?? '')) . '</p></article>';
}
return $html . '</div>';
}
private static function highlights(array $i): string {
$html = '<div class="aksrt-widget-grid aksrt-highlights" style="--aksrt-cols:' . (int) $i['pc_row'] . ';--aksrt-cols-m:' . (int) $i['m_row'] . '">';
foreach ((array) $i['items'] as $index => $item) {
$html .= RendererHelpers::card_media((array) $item, 'aksrt-highlight aksrt-highlight--' . (($index % 5 === 0) ? 'large' : 'normal'));
}
return $html . '</div>';
}
private static function feature_intro(array $i): string {
$tabs = $panels = '';
foreach ((array) $i['items'] as $index => $item) {
$tabs .= '<button type="button" role="tab" data-feature-index="' . $index . '" aria-selected="' . ($index === 0 ? 'true' : 'false') . '">' . esc_html((string) ($item['title'] ?? '')) . '</button>';
$features = '';
foreach (RendererHelpers::lines((string) ($item['features'] ?? '')) as $feature) {
$features .= '<li>' . esc_html($feature) . '</li>';
}
$panels .= '<article role="tabpanel" data-feature-panel="' . $index . '"' . ($index ? ' hidden' : '') . '><div class="aksrt-feature-intro__media">' . RendererHelpers::card_media((array) $item) . '</div><div class="aksrt-feature-intro__copy"><h3>' . esc_html((string) ($item['title'] ?? '')) . '</h3><p>' . esc_html((string) ($item['description'] ?? '')) . '</p><ul>' . $features . '</ul></div></article>';
}
return '<div class="aksrt-feature-intro"><div class="aksrt-feature-intro__tabs" role="tablist">' . $tabs . '</div><div class="aksrt-feature-intro__panels">' . $panels . '</div></div>';
}
private static function tilt(array $i): string {
$html = '<div class="aksrt-widget-grid aksrt-tilt-cases" style="--aksrt-cols:' . (int) $i['pc_row'] . ';--aksrt-cols-m:' . (int) $i['m_row'] . '">';
foreach ((array) $i['items'] as $item) {
$html .= '<div class="aksrt-tilt-case" data-aksrt-tilt data-tilt-max="9" data-tilt-speed="500">' . RendererHelpers::card_media((array) $item) . '</div>';
}
return $html . '</div>';
}
}
@@ -0,0 +1,55 @@
<?php
namespace AKSRT\WidgetSystem\Renderers;
use AKSRT\WidgetSystem\RendererHelpers;
defined('ABSPATH') || exit;
/** Render full sliders and the slider/media combination. */
final class SliderRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
unset($context);
$slides = '';
foreach ((array) $instance['slides'] as $slide) {
if (!is_array($slide) || (empty($slide['background']) && empty($slide['background_video']))) {
continue;
}
$link = RendererHelpers::link((array) ($slide['link'] ?? []));
$media = !empty($slide['background_video'])
? '<video src="' . esc_url((string) $slide['background_video']) . '" muted loop playsinline preload="metadata"></video>'
: RendererHelpers::image((string) $slide['background'], (string) ($slide['title'] ?? ''), 'swiper-lazy');
$layers = '';
foreach ((array) ($slide['layers'] ?? []) as $layer) {
if (!empty($layer['image'])) {
$layers .= '<img class="aksrt-slide-layer" src="' . esc_url((string) $layer['image']) . '" alt="" data-swiper-parallax="' . (int) ($layer['parallax'] ?? 0) . '" data-swiper-parallax-scale="' . esc_attr((string) ((float) ($layer['scale'] ?? 100) / 100)) . '">';
}
}
$copy = '<div class="aksrt-slide-copy is-' . esc_attr((string) ($slide['text_align'] ?? 'left-bottom')) . '"><h3>' . esc_html((string) ($slide['title'] ?? '')) . '</h3><p>' . esc_html((string) ($slide['description'] ?? '')) . '</p></div>';
$body = $media . $layers . $copy;
if ($link['url']) {
$body = '<a href="' . $link['url'] . '"' . RendererHelpers::link_attrs($link) . '>' . $body . '</a>';
}
$slides .= '<div class="swiper-slide' . (!empty($slide['hide_mobile']) ? ' aksrt-slide--hide-mobile' : '') . '">' . $body . '</div>';
}
if ($slides === '') {
return RendererHelpers::empty_state(__('请配置幻灯片', 'aksrt'));
}
$slider = '<div class="swiper aksrt-widget-swiper aksrt-slider" data-aksrt-swiper="slider" data-direction="' . esc_attr((string) $instance['direction']) . '" data-effect="' . esc_attr((string) $instance['effect']) . '" data-loop="' . (!empty($instance['loop']) ? '1' : '0') . '" data-autoplay="' . (!empty($instance['autoplay']) ? '1' : '0') . '" data-interval="' . ((int) $instance['interval'] * 1000) . '" data-speed="' . (int) $instance['speed'] . '" data-space="' . (int) $instance['spacebetween'] . '" style="--aksrt-slider-height:' . (int) $instance['pc_height'] . 'px;--aksrt-slider-height-m:' . (int) $instance['m_height'] . 'px"><div class="swiper-wrapper">' . $slides . '</div>';
if (!empty($instance['button'])) {
$slider .= '<button type="button" class="swiper-button-prev" aria-label="' . esc_attr__('上一张', 'aksrt') . '"></button><button type="button" class="swiper-button-next" aria-label="' . esc_attr__('下一张', 'aksrt') . '"></button>';
}
if (!empty($instance['pagination'])) {
$slider .= '<div class="swiper-pagination"></div>';
}
$slider .= '</div>';
if ($slug !== 'slider_media_combo') {
return $slider;
}
$cards = '';
foreach ((array) ($instance['side_cards'] ?? []) as $card) {
$cards .= RendererHelpers::card_media((array) $card);
}
return '<div class="aksrt-slider-combo"><div class="aksrt-slider-combo__main">' . $slider . '</div><aside class="aksrt-slider-combo__side">' . $cards . '</aside></div>';
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace AKSRT\WidgetSystem\Renderers;
use AKSRT\WidgetSystem\RendererHelpers;
defined('ABSPATH') || exit;
/** Render login, author, ranking and user-list widgets. */
final class UserRenderer {
public static function render(string $slug, array $instance, array $context = []): string {
unset($context);
switch ($slug) {
case 'user_card': return self::user_card($instance);
case 'author_card': return self::author_card($instance);
case 'user_ranking': return self::ranking($instance);
case 'user_list': return self::user_list($instance);
}
return '';
}
private static function user_card(array $i): string {
$user_id = get_current_user_id();
if ($user_id <= 0) {
$login = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url();
$register = function_exists('aksrt_get_register_url') ? aksrt_get_register_url() : wp_registration_url();
$oauth_html = '';
if (class_exists('AKSRT\\Auth\\OAuth')) {
$oauth = \AKSRT\Auth\OAuth::get_instance();
if (is_callable([$oauth, 'render_social_buttons'])) {
$oauth_html = (string) $oauth->render_social_buttons(__('社交账号登录', 'aksrt'));
} elseif (is_callable([$oauth, 'get_login_buttons'])) {
$oauth_html = (string) $oauth->get_login_buttons();
}
}
return '<div class="aksrt-user-card is-guest"><div class="aksrt-user-card__cover"></div><div class="aksrt-user-card__body">' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html(0, 88) : get_avatar(0, 88)) . '<h3>' . esc_html__('Hi,请登录', 'aksrt') . '</h3><p>' . esc_html__('登录后管理课程、会员、订单与创作内容。', 'aksrt') . '</p><div class="aksrt-user-card__actions"><a class="aksrt-widget-button signin-loader" href="' . esc_url($login) . '">' . esc_html__('登录', 'aksrt') . '</a><a class="aksrt-widget-button is-ghost signup-loader" href="' . esc_url($register) . '">' . esc_html__('注册', 'aksrt') . '</a></div>' . ($oauth_html !== '' ? '<div class="aksrt-user-card__oauth">' . $oauth_html . '</div>' : '') . '</div></div>';
}
$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 = '<article class="aksrt-user-card' . (!$self_card ? ' is-author' : '') . (empty($i['show_cover']) ? ' no-cover' : '') . '">';
if (!empty($i['show_cover'])) {
$html .= '<div class="aksrt-user-card__cover"' . ($cover ? ' style="background-image:url(' . esc_url($cover) . ')"' : '') . '></div>';
}
$html .= '<div class="aksrt-user-card__body"><a class="aksrt-user-card__avatar" href="' . esc_url($home) . '">' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($user_id, 96) : get_avatar($user_id, 96)) . '</a><h3><a href="' . esc_url($home) . '">' . esc_html($user->display_name) . '</a>' . (function_exists('aksrt_render_vip_badge') ? aksrt_render_vip_badge($user_id) : '') . '</h3>';
if (function_exists('aksrt_render_user_identity_chips')) {
$html .= '<div class="aksrt-user-card__identity">' . aksrt_render_user_identity_chips($user_id, 'widget') . '</div>';
}
$html .= '<p>' . esc_html(wp_trim_words((string) $user->description, 24)) . '</p><div class="aksrt-user-card__stats"><span><b>' . number_format_i18n(count_user_posts($user_id, 'post', true)) . '</b>' . esc_html__('文章', 'aksrt') . '</span><span><b>' . number_format_i18n($followers) . '</b>' . esc_html__('粉丝', 'aksrt') . '</span><span><b>' . number_format_i18n($following) . '</b>' . esc_html__('关注', 'aksrt') . '</span></div>';
if (!empty($i['show_buttons'])) {
$html .= '<div class="aksrt-user-card__actions">' . self::action((string) $i['primary_action'], $user, true) . self::action((string) $i['secondary_action'], $user, false) . '</div>';
}
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 .= '<button class="aksrt-user-card__follow' . ($active ? ' is-following' : '') . '" type="button" data-aksrt-follow="' . $user_id . '">' . esc_html($active ? __('已关注', 'aksrt') : __('关注', 'aksrt')) . '</button>';
}
$html .= '</div>';
if (!empty($i['show_posts']) && function_exists('aksrt_render_user_card_posts')) {
$html .= '<div class="aksrt-user-card__posts">' . aksrt_render_user_card_posts(['user_id' => $user_id, 'count' => 4, 'post_style' => 'mini', 'show_empty' => false]) . '</div>';
}
return $html . '</article>';
}
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 '<span class="aksrt-user-card__checkin">' . aksrt_header_user_checkin_btn((int) $user->ID, true) . '</span>';
}
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 '<a class="aksrt-widget-button' . (!$primary ? ' is-ghost' : '') . '" href="' . esc_url($url) . '">' . esc_html($labels[$action] ?? $labels['home']) . '</a>';
}
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 = '<ol class="aksrt-user-ranking">';
foreach ($users as $index => $user) {
$html .= '<li><span class="aksrt-user-ranking__index' . (!empty($i['show_badge']) && $index < 3 ? ' is-top' : '') . '">' . ($index + 1) . '</span>' . self::user_tile($user, self::metric($user, $metric), self::metric_label($metric)) . '</li>';
}
return $html . '</ol>';
}
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 = '<div class="aksrt-widget-grid aksrt-user-list">';
foreach (array_slice($users, 0, (int) $i['number']) as $user) {
$html .= '<article>' . self::user_tile($user, self::metric($user, $orderby), self::metric_label($orderby)) . '</article>';
}
return $html . '</div>';
}
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 '<a class="aksrt-user-tile" href="' . esc_url($url) . '">' . (function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($user->ID, 64) : get_avatar($user->ID, 64)) . '<span><strong>' . esc_html($user->display_name) . '</strong><small>' . esc_html($label) . ' ' . number_format_i18n($value) . '</small></span></a>';
}
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'];
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/** Public read-only endpoints for saved widget instances and quotes. */
final class RestController extends \WP_REST_Controller {
public function __construct() {
$this->namespace = 'aksrt/v1';
$this->rest_base = 'widgets';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<widget>[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<string,mixed>|\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]);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* One reusable WP_Widget implementation backed by a WidgetDefinition.
*/
final class SchemaWidget extends \WP_Widget {
private WidgetDefinition $definition;
public function __construct(WidgetDefinition $definition) {
$this->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(
'<div class="aksrt-widget-form-lazy" data-id-base="%1$s" data-widget-number="%2$s"><p class="aksrt-widget-form-lazy__status">%3$s</p><button type="button" class="button aksrt-widget-form-lazy__load">%4$s</button></div>',
esc_attr($this->id_base),
esc_attr((string) $this->number),
esc_html__('配置将在展开或添加模块后加载。', 'aksrt'),
esc_html__('加载配置', 'aksrt')
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Canonical catalog of the 37 modern AKSRT widgets.
*/
final class WidgetCatalog {
public static function definitions(): array {
$groups = [
'general' => [
['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 [];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Immutable description of one schema-driven widget.
*/
final class WidgetDefinition {
private string $slug;
private string $name;
private string $description;
private string $group;
private string $renderer;
private string $size;
private array $fields;
private array $common;
private array $assets;
public function __construct(array $definition) {
$slug = (string) ($definition['slug'] ?? '');
if (!preg_match('/^[a-z][a-z0-9_]*$/', $slug)) {
throw new \InvalidArgumentException('Invalid widget slug: ' . $slug);
}
$name = trim((string) ($definition['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Widget name is required for ' . $slug);
}
$this->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);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Lazy, immutable registry for all modern widgets.
*/
final class WidgetRegistry {
/** @var array<string, WidgetDefinition>|null */
private static ?array $definitions = null;
/** @return array<string, WidgetDefinition> */
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;
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/**
* Business field schemas. Rendering behavior is implemented in phase 2.
*/
final class WidgetSchemas {
public static function for(string $slug): array {
if (in_array($slug, ['term_cards', 'term_aggregation', 'hot_posts', 'posts', 'tab_posts', 'oneline_posts'], true)) {
return self::content($slug);
}
if ($slug === 'post_pay') {
return [
['id' => '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' => '签到'];
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
if (!function_exists('sanitize_text_field')) {
function sanitize_text_field($value): string {
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
}
}
if (!function_exists('sanitize_textarea_field')) {
function sanitize_textarea_field($value): string {
return trim(strip_tags((string) $value));
}
}
if (!function_exists('wp_kses_post')) {
function wp_kses_post($value): string {
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
}
}
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' => '<script>alert(1)</script> 最新课程',
'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'], '<script>')) {
$errors[] = 'Text sanitization failed.';
}
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_file), true);
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
$errors[] = 'Reference baseline is missing or invalid.';
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
foreach ((array) ($baseline['files'] ?? []) as $file) {
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
if (!is_file($path)) {
$errors[] = 'Reference file missing: ' . $path;
continue;
}
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
$errors[] = 'Reference file changed: ' . $file['path'];
}
}
}
if ($errors) {
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
exit(1);
}
echo "Widget system audit passed: 37 definitions/renderers, frozen baseline, query/REST/assets contracts.\n";