feat(widgets): reviews + highlights carousel cards; plate-aware board list

Add zibll reviews/highlights marketing widgets with CSS, and make the
legacy forum board-list widget prefer aksrt_forum_plate CPT.
This commit is contained in:
2026-07-19 00:25:43 +08:00
parent d8682ce1fe
commit 47d202bb96
6 changed files with 491 additions and 23 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* Batch3c: reviews + highlights.
*/
.aksrt-reviews {
margin-bottom: 16px;
--aksrt-rev-pc: 3;
--aksrt-rev-m: 1.2;
}
.aksrt-reviews__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc((100% - (var(--aksrt-rev-pc) - 1) * 12px) / var(--aksrt-rev-pc));
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 6px;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 767.98px) {
.aksrt-reviews__track {
grid-auto-columns: calc((100% - (var(--aksrt-rev-m) - 1) * 12px) / var(--aksrt-rev-m));
}
}
.aksrt-reviews__slide { scroll-snap-align: start; min-width: 0; }
.aksrt-reviews__card {
position: relative;
border: 1px solid var(--color-line, #e5e7eb);
border-radius: 12px;
padding: 16px;
background: var(--surface-1, #fff);
min-height: 100%;
}
.aksrt-reviews__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.aksrt-reviews__avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: var(--surface-2, #eee);
display: inline-flex;
align-items: center;
justify-content: center;
color: #999;
flex-shrink: 0;
}
.aksrt-reviews__name { font-weight: 700; font-size: 14px; }
.aksrt-reviews__name a { color: inherit; text-decoration: none; }
.aksrt-reviews__role { font-size: 12px; color: var(--muted-2-color, #888); }
.aksrt-reviews__stars { margin-bottom: 8px; font-size: 12px; }
.aksrt-reviews__stars .is-on { color: #f5a623; }
.aksrt-reviews__stars .is-off { color: #ddd; }
.aksrt-reviews__content {
font-size: 13px;
line-height: 1.55;
color: var(--color-ink-2, #444);
margin: 0;
}
.aksrt-reviews__quote {
position: absolute;
right: 14px;
bottom: 10px;
opacity: .12;
font-size: 28px;
}
.aksrt-highlights { margin-bottom: 16px; display: flex; flex-direction: column; gap: 12px; }
.aksrt-highlights__row {
display: grid;
gap: 12px;
}
.aksrt-highlights__row.is-large { grid-template-columns: 1fr; }
.aksrt-highlights__row.is-medium { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.aksrt-highlights__row.is-small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 767.98px) {
.aksrt-highlights__row.is-medium,
.aksrt-highlights__row.is-small { grid-template-columns: 1fr; }
}
.aksrt-hl-card {
display: flex;
gap: 12px;
border-radius: 12px;
border: 1px solid var(--color-line, #e5e7eb);
background: var(--surface-1, #fff);
padding: 14px;
text-decoration: none;
color: inherit;
overflow: hidden;
min-height: 100%;
}
.aksrt-hl-card.color-blue { --hl: #3b82f6; }
.aksrt-hl-card.color-pink { --hl: #ec4899; }
.aksrt-hl-card.color-purple { --hl: #a855f7; }
.aksrt-hl-card.color-green { --hl: #22c55e; }
.aksrt-hl-card.color-orange { --hl: #f97316; }
.aksrt-hl-card.color-cyan { --hl: #06b6d4; }
.aksrt-hl-card.size-large { min-height: 140px; }
.aksrt-hl-card__media {
width: 96px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
background: var(--surface-2, #f3f4f6);
}
.aksrt-hl-card.size-large .aksrt-hl-card__media { width: 160px; }
.aksrt-hl-card__media img { width: 100%; height: 100%; object-fit: cover; display: block; min-height: 80px; }
.aksrt-hl-card__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.aksrt-hl-card__icon { color: var(--hl, #3b82f6); font-size: 22px; margin-bottom: 8px; }
.aksrt-hl-card__title { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
.aksrt-hl-card__desc { font-size: 12px; color: var(--muted-2-color, #888); line-height: 1.5; }
+1
View File
@@ -4,3 +4,4 @@
@import url("widgets/part-4.css");
@import url("widget-slider.css");
@import url("widget-batch3.css");
@import url("widget-batch3c.css");
+2
View File
@@ -66,6 +66,8 @@ function aksrt_register_widgets() {
register_widget('\AKSRT\Widgets\WidgetPricingPlans');
register_widget('\AKSRT\Widgets\WidgetFaq');
register_widget('\AKSRT\Widgets\WidgetStats');
register_widget('\AKSRT\Widgets\WidgetReviews');
register_widget('\AKSRT\Widgets\WidgetHighlights');
// Forum widgets
+52 -23
View File
@@ -26,31 +26,60 @@ class WidgetForumBoardList extends \WP_Widget {
echo $args['before_widget'];
echo $args['before_title'] . esc_html($title) . $args['after_title'];
$boards = get_terms([
'taxonomy' => 'aksrt_forum_board',
'hide_empty' => true,
'orderby' => 'meta_value_num',
'meta_key' => '_aksrt_sort_order',
'order' => 'ASC',
]);
if (!is_wp_error($boards) && !empty($boards)) {
echo '<ul class="aksrt-forum-widget__boards">';
foreach ($boards as $board) {
$icon = get_term_meta($board->term_id, '_aksrt_icon', true);
echo '<li class="aksrt-forum-widget__board">';
echo '<a href="' . esc_url(get_term_link($board)) . '">';
if ($icon) {
echo '<i class="' . esc_attr($icon) . '"></i> ';
$rendered = false;
if (post_type_exists('aksrt_forum_plate')) {
$plates = get_posts([
'post_type' => 'aksrt_forum_plate',
'post_status' => 'publish',
'posts_per_page' => 30,
'orderby' => 'menu_order title',
'order' => 'ASC',
]);
if ($plates) {
$rendered = true;
echo '<ul class="aksrt-forum-widget__boards">';
foreach ($plates as $plate) {
$icon = (string) get_post_meta($plate->ID, '_aksrt_icon', true);
$count = (int) get_post_meta($plate->ID, '_aksrt_posts_count', true);
echo '<li class="aksrt-forum-widget__board">';
echo '<a href="' . esc_url(get_permalink($plate)) . '">';
if ($icon !== '') {
echo '<i class="' . esc_attr($icon) . '"></i> ';
}
echo esc_html(get_the_title($plate));
echo '<span class="aksrt-forum-widget__count">' . $count . '</span>';
echo '</a>';
echo '</li>';
}
echo esc_html($board->name);
echo '<span class="aksrt-forum-widget__count">' . (int) $board->count . '</span>';
echo '</a>';
echo '</li>';
echo '</ul>';
}
}
if (!$rendered) {
$boards = get_terms([
'taxonomy' => 'aksrt_forum_board',
'hide_empty' => true,
'orderby' => 'meta_value_num',
'meta_key' => '_aksrt_sort_order',
'order' => 'ASC',
]);
if (!is_wp_error($boards) && !empty($boards)) {
echo '<ul class="aksrt-forum-widget__boards">';
foreach ($boards as $board) {
$icon = get_term_meta($board->term_id, '_aksrt_icon', true);
echo '<li class="aksrt-forum-widget__board">';
echo '<a href="' . esc_url(get_term_link($board)) . '">';
if ($icon) {
echo '<i class="' . esc_attr($icon) . '"></i> ';
}
echo esc_html($board->name);
echo '<span class="aksrt-forum-widget__count">' . (int) $board->count . '</span>';
echo '</a>';
echo '</li>';
}
echo '</ul>';
} else {
echo '<p class="aksrt-forum-widget__empty">' . esc_html__('暂无版块', 'aksrt') . '</p>';
}
echo '</ul>';
} else {
echo '<p class="aksrt-forum-widget__empty">' . esc_html__('暂无版块', 'aksrt') . '</p>';
}
echo $args['after_widget'];
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Highlights cards (zibll zib_widget_highlights lite).
*
* @package AKSRT
*/
namespace AKSRT\Widgets;
defined('ABSPATH') || exit;
class WidgetHighlights extends \WP_Widget {
public function __construct() {
parent::__construct(
'aksrt_highlights',
esc_html__('AKSRT 亮点卡片', 'aksrt'),
[
'description' => esc_html__('大中小尺寸亮点/卖点卡片栅格。', 'aksrt'),
'classname' => 'widget_aksrt_highlights',
]
);
}
public function widget($args, $instance): void {
$cards = $this->normalize($instance['cards'] ?? []);
if (!$cards) {
return;
}
$go_link = !empty($instance['go_link']);
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$title = apply_filters('widget_title', (string) ($instance['title'] ?? ''), $instance, $this->id_base);
if ($title !== '') {
echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
$large = array_values(array_filter($cards, static fn($c) => ($c['size'] ?? 'medium') === 'large'));
$medium = array_values(array_filter($cards, static fn($c) => ($c['size'] ?? 'medium') === 'medium'));
$small = array_values(array_filter($cards, static fn($c) => ($c['size'] ?? 'medium') === 'small'));
// Fallback: if no size tags, treat all as medium.
if (!$large && !$medium && !$small) {
$medium = $cards;
}
echo '<div class="aksrt-highlights">';
if ($large) {
echo '<div class="aksrt-highlights__row is-large">';
foreach ($large as $c) {
echo $this->card($c, 'large', $go_link); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
}
if ($medium) {
echo '<div class="aksrt-highlights__row is-medium">';
foreach ($medium as $c) {
echo $this->card($c, 'medium', $go_link); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
}
if ($small) {
echo '<div class="aksrt-highlights__row is-small">';
foreach ($small as $c) {
echo $this->card($c, 'small', $go_link); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
}
echo '</div>';
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* @param array<string,mixed> $c
*/
private function card(array $c, string $size, bool $go_link): string {
$color = sanitize_key((string) ($c['color'] ?? 'blue'));
if (!in_array($color, ['blue', 'pink', 'purple', 'green', 'orange', 'cyan'], true)) {
$color = 'blue';
}
$url = (string) ($c['url'] ?? '');
if ($url !== '' && $go_link && function_exists('aksrt_go_link')) {
$url = aksrt_go_link($url, true);
}
$tag = $url !== '' ? 'a' : 'div';
$href = $url !== '' ? ' href="' . esc_url($url) . '"' : '';
$img = (string) ($c['image'] ?? '');
$html = '<' . $tag . ' class="aksrt-hl-card size-' . esc_attr($size) . ' color-' . esc_attr($color) . '"' . $href . '>';
if ($img !== '') {
$html .= '<div class="aksrt-hl-card__media"><img src="' . esc_url($img) . '" alt="" loading="lazy" /></div>';
}
$html .= '<div class="aksrt-hl-card__body">';
if (!empty($c['icon'])) {
$html .= '<div class="aksrt-hl-card__icon"><i class="' . esc_attr((string) $c['icon']) . '" aria-hidden="true"></i></div>';
}
if ($c['title'] !== '') {
$html .= '<div class="aksrt-hl-card__title">' . esc_html($c['title']) . '</div>';
}
if ($c['desc'] !== '') {
$html .= '<div class="aksrt-hl-card__desc">' . esc_html($c['desc']) . '</div>';
}
$html .= '</div></' . $tag . '>';
return $html;
}
/**
* @param mixed $raw
* @return array<int, array<string,mixed>>
*/
private function normalize($raw): array {
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (!is_array($row)) {
continue;
}
$title = sanitize_text_field((string) ($row['title'] ?? ''));
$desc = sanitize_text_field((string) ($row['desc'] ?? ''));
if ($title === '' && $desc === '') {
continue;
}
$size = sanitize_key((string) ($row['size'] ?? 'medium'));
if (!in_array($size, ['large', 'medium', 'small'], true)) {
$size = 'medium';
}
$out[] = [
'title' => $title,
'desc' => $desc,
'icon' => sanitize_text_field((string) ($row['icon'] ?? '')),
'image' => esc_url_raw(trim((string) ($row['image'] ?? ''))),
'url' => esc_url_raw(trim((string) ($row['url'] ?? ''))),
'color' => sanitize_key((string) ($row['color'] ?? 'blue')),
'size' => $size,
];
if (count($out) >= 12) {
break;
}
}
return $out;
}
public function form($instance): void {
$json = is_string($instance['cards'] ?? null)
? (string) $instance['cards']
: wp_json_encode($this->normalize($instance['cards'] ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?>
<p>
<label><?php esc_html_e('标题', 'aksrt'); ?>
<input class="widefat" type="text" name="<?php echo esc_attr($this->get_field_name('title')); ?>" value="<?php echo esc_attr((string) ($instance['title'] ?? '')); ?>">
</label>
</p>
<p>
<label><?php esc_html_e('卡片 JSON', 'aksrt'); ?>
<textarea class="widefat" rows="8" name="<?php echo esc_attr($this->get_field_name('cards')); ?>" placeholder='[{"title":"高速","desc":"…","size":"large","color":"blue","icon":"fas fa-bolt"}]'><?php echo esc_textarea((string) $json); ?></textarea>
</label>
<small><?php esc_html_e('size: large|medium|small;color: blue/pink/…', 'aksrt'); ?></small>
</p>
<p>
<label><input type="checkbox" name="<?php echo esc_attr($this->get_field_name('go_link')); ?>" value="1" <?php checked(!empty($instance['go_link'])); ?>> <?php esc_html_e('外链跳转页', 'aksrt'); ?></label>
</p>
<?php
}
public function update($new_instance, $old_instance): array {
return [
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
'cards' => $this->normalize($new_instance['cards'] ?? []),
'go_link' => !empty($new_instance['go_link']) ? 1 : 0,
];
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
/**
* Testimonials / virtual reviews carousel (zibll zib_widget_reviews lite).
*
* @package AKSRT
*/
namespace AKSRT\Widgets;
defined('ABSPATH') || exit;
class WidgetReviews extends \WP_Widget {
public function __construct() {
parent::__construct(
'aksrt_reviews',
esc_html__('AKSRT 用户评价', 'aksrt'),
[
'description' => esc_html__('虚拟用户评价卡片横向滚动。', 'aksrt'),
'classname' => 'widget_aksrt_reviews',
]
);
}
public function widget($args, $instance): void {
$reviews = $this->normalize($instance['reviews'] ?? []);
if (!$reviews) {
return;
}
$pc = max(1, min(6, (float) ($instance['desktop_per_view'] ?? 3)));
$m = max(1, min(3, (float) ($instance['mobile_per_view'] ?? 1.2)));
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$title = apply_filters('widget_title', (string) ($instance['title'] ?? ''), $instance, $this->id_base);
if ($title !== '') {
echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
$style = sprintf('--aksrt-rev-pc:%s;--aksrt-rev-m:%s;', rtrim(rtrim(number_format($pc, 2, '.', ''), '0'), '.'), rtrim(rtrim(number_format($m, 2, '.', ''), '0'), '.'));
echo '<div class="aksrt-reviews" style="' . esc_attr($style) . '">';
echo '<div class="aksrt-reviews__track">';
foreach ($reviews as $r) {
echo '<div class="aksrt-reviews__slide"><div class="aksrt-reviews__card">';
echo '<div class="aksrt-reviews__head">';
if ($r['avatar'] !== '') {
echo '<img class="aksrt-reviews__avatar" src="' . esc_url($r['avatar']) . '" alt="" loading="lazy" width="44" height="44" />';
} else {
echo '<span class="aksrt-reviews__avatar is-placeholder"><i class="fa fa-user" aria-hidden="true"></i></span>';
}
echo '<div class="aksrt-reviews__meta">';
if ($r['name'] !== '') {
$name_html = esc_html($r['name']);
if ($r['url'] !== '') {
$name_html = '<a href="' . esc_url($r['url']) . '" target="_blank" rel="noopener noreferrer">' . $name_html . '</a>';
}
echo '<div class="aksrt-reviews__name">' . $name_html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
if ($r['role'] !== '') {
echo '<div class="aksrt-reviews__role">' . esc_html($r['role']) . '</div>';
}
echo '</div></div>';
echo '<div class="aksrt-reviews__stars" aria-label="' . esc_attr(sprintf(__('%d 星', 'aksrt'), $r['rating'])) . '">';
for ($i = 1; $i <= 5; $i++) {
echo '<i class="fa fa-star' . ($i <= $r['rating'] ? ' is-on' : ' is-off') . '" aria-hidden="true"></i>';
}
echo '</div>';
if ($r['content'] !== '') {
echo '<p class="aksrt-reviews__content">' . nl2br(esc_html($r['content'])) . '</p>';
}
echo '<i class="fa fa-quote-right aksrt-reviews__quote" aria-hidden="true"></i>';
echo '</div></div>';
}
echo '</div></div>';
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* @param mixed $raw
* @return array<int, array{name:string,role:string,avatar:string,content:string,rating:int,url:string}>
*/
private function normalize($raw): array {
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (!is_array($row)) {
continue;
}
$name = sanitize_text_field((string) ($row['name'] ?? ''));
$content = sanitize_textarea_field((string) ($row['content'] ?? ''));
if ($name === '' && $content === '') {
continue;
}
$out[] = [
'name' => $name,
'role' => sanitize_text_field((string) ($row['role'] ?? '')),
'avatar' => esc_url_raw(trim((string) ($row['avatar'] ?? ''))),
'content' => $content,
'rating' => max(0, min(5, (int) ($row['rating'] ?? 5))),
'url' => esc_url_raw(trim((string) ($row['url'] ?? ''))),
];
if (count($out) >= 20) {
break;
}
}
return $out;
}
public function form($instance): void {
$json = is_string($instance['reviews'] ?? null)
? (string) $instance['reviews']
: wp_json_encode($this->normalize($instance['reviews'] ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?>
<p>
<label><?php esc_html_e('标题', 'aksrt'); ?>
<input class="widefat" type="text" name="<?php echo esc_attr($this->get_field_name('title')); ?>" value="<?php echo esc_attr((string) ($instance['title'] ?? '')); ?>">
</label>
</p>
<p>
<label><?php esc_html_e('评价 JSON', 'aksrt'); ?>
<textarea class="widefat" rows="8" name="<?php echo esc_attr($this->get_field_name('reviews')); ?>" placeholder='[{"name":"张三","role":"站长","rating":5,"content":"很好用","avatar":""}]'><?php echo esc_textarea((string) $json); ?></textarea>
</label>
</p>
<p>
<label><?php esc_html_e('PC 每屏', 'aksrt'); ?>
<input class="tiny-text" type="number" min="1" max="6" step="0.1" name="<?php echo esc_attr($this->get_field_name('desktop_per_view')); ?>" value="<?php echo esc_attr((string) ($instance['desktop_per_view'] ?? '3')); ?>">
</label>
<label><?php esc_html_e('移动每屏', 'aksrt'); ?>
<input class="tiny-text" type="number" min="1" max="3" step="0.1" name="<?php echo esc_attr($this->get_field_name('mobile_per_view')); ?>" value="<?php echo esc_attr((string) ($instance['mobile_per_view'] ?? '1.2')); ?>">
</label>
</p>
<?php
}
public function update($new_instance, $old_instance): array {
return [
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
'reviews' => $this->normalize($new_instance['reviews'] ?? []),
'desktop_per_view' => max(1, min(6, (float) ($new_instance['desktop_per_view'] ?? 3))),
'mobile_per_view' => max(1, min(3, (float) ($new_instance['mobile_per_view'] ?? 1.2))),
];
}
}